API Documentation

Integrate PSA analysis into your applications via the REST API. Requires a Pro or Enterprise plan.

Authentication

Include your API key in the Authorization header:

Authorization: Bearer psa_your_api_key_here

Generate API keys from Settings.

✓ API KEY AUTHENTICATION

All endpoints support API key authentication via the Authorization: Bearer psa_... header. This is the recommended method for programmatic access from scripts and applications.

Base URL

https://splabs.io

Health

GET /ping

Lightweight health check. No auth required, no DB dependency.

{ "status": "ok" }
GET /health

Full health check with DB connectivity test. Returns 503 if DB unreachable.

{ "status": "ok", "db": "connected" }

Public API v1

v1

Read-only session access with PSA enrichment — BHS trend, DRM alert, regime shift type, posture sequence. Prefix: /v1/

Sessions

GET /v1/sessions

Paginated session list with PSA enrichment — BHS trend, DRM alert, regime type. Includes an unfiltered summary object drawn from pre-computed stats (O(1), no table scan).

Query Parameters

pageinteger, default 1
per_pageinteger, default 25, max 200
searchsession name filter
alertcomma-separated levels: RED,YELLOW
sortcreated_at (default) | name | max_alert | n_turns
orderdesc (default) | asc
curl "https://splabs.io/v1/sessions?per_page=25&page=1" \
  -H "Authorization: Bearer psa_your_key"
{
  "sessions": [{ "id": "...", "name": "...", "max_alert": "RED",
                  "avg_bhs": 0.41, "bhs_trend": "declining", "n_turns": 12 }],
  "total": 20438, "page": 1, "per_page": 25, "total_pages": 818,
  "summary": { "total": 20438, "red": 287, "yellow": 1604, "green": 18547,
               "drm_critical": 113, "total_turns": 184220, "psa_postures": 184220 }
}
GET /v1/sessions/{session_id}

Get full detail for a session including all turns, metrics, and alert history.

curl https://splabs.io/v1/sessions/your-session-uuid \
  -H "Authorization: Bearer psa_your_key"

PSA v2 — Posture Sequence Analysis

v2

Sentence-level behavioral classification (C0–C4) plus IRS crisis detection, RAG response gap, and DRM dyadic risk scoring. Prefix: /api/v2/psa/

POST /api/v2/psa/public-analyze Public · no auth

Free, unauthenticated PSA analysis powering the landing widget. Stateless (analysis never persisted; only aggregate counters written). Burst-limited 30/min per IP, plus DB-backed daily caps (global 10k shared pool, per-IP 10 for unauthenticated visitors); max 12 turns / 8000 chars. Send either turns (multi-turn) or text (single response). Returns per-turn PSA results plus tokens — the real MiniLM subword-token count processed (not a billing/credit figure).

Request Body

{
  "turns": [ {"user": "How do I do X?", "model": "I can help with that."} ],
  "clf_context": "clinical"
}
GET /api/v2/psa/public-stats Public · no auth

Honest stats for the landing: live cumulative analyses_run (O(1) single-row read, maintained at write time, seeded from real history) + today / remaining_today + traffic-independent capability figures (classifiers 13, metrics 37, behavioral_classes 116, languages 5, cpf_indicators 100) from METRICS_REFERENCE.

POST /api/v2/psa/analyze

Analyze a conversation turn. Supports three modes: agent-only (response_text only), user-only (user_text only), or full pair (both). The turn_type field in the response reflects which side was analyzed.

Request Body

{
  "response_text": "The AI response to analyze",   // absent for user_only turns
  "user_text": "The human message for this turn",  // absent for agent_only turns
  "input_text": "optional — legacy alias for user_text",
  "session_id": "your-session-uuid",   // OR use session_name (one required unless dry_run)
  "session_name": "my-session",        // auto-creates on first call, looked up after
  "turn": 1,
  "dry_run": false,                    // true = stateless analysis, no DB write
  "save_text": "all",                  // "all"|"user"|"agent"|"none" — which sentences to persist
  "include_user_hx": false             // true = include H2/H3/H4/H5 user classifier scores in response
}

At least one of response_text or user_text must be provided — omitting both returns 422.
Either session_id or session_name is required in normal mode (omitting both returns 503).
turn_type is derived server-side: full (both sides), agent_only (response only), user_only (user message only).
save_text: "all" saves both user and agent sentences (default); "user" saves only c0_sentences; "agent" saves only c1_sentences; "none" stores metrics only — no raw text. Ignored when dry_run: true.
include_user_hx: opt-in flag — when true and user_text is present, adds user_hx to the response with H2 (relational dynamics), H3 (cognitive patterns), H4 (social dynamics), H5 (adversarial patterns) scores. Computed synchronously, adds ~5ms. Default false for backward compatibility.

Response

{
  "session_id": "550e8400-e29b-41d4-a716-446655440000",
  "turn": 1,
  "turn_type": "full",                 // "full" | "agent_only" | "user_only"
  "c1": { "postures": [0,2,1], "sentences": ["..."], "poi": 0.33, "pe": 0.91, "dpi": 0.07, "mps": 2 },
  "c2": { "postures": [0,0,1], "confidences": [0.91,0.88,0.72], "sd": 0.08 },
  "c3": { "postures": [0,0,0], "hri": 0.0 },
  "c4": { "postures": [1,0,2], "pd": 0.15, "td": 2 },
  "c0": { "postures": [3,1], "confidences": [0.91,0.84], "cpi": 0.8 },
  "bhs": 0.87,
  "alert": "green",
  "incongruence": null,
  "irs": { "irs_composite": 0.81, "irs_level": "critical",
           "suicidality_signal": 0.90, "dissociation_signal": 0.0,
           "grandiosity_signal": 0.0, "urgency_signal": 0.55,
           "frame_break": { "detected": true, "score": 0.94,
             "sentence_index": 2,
             "frame_break_sentence": "I keep thinking about the specific sound the chair made." } },
  "ras": { "ras_composite": 0.18, "ras_level": "inadequate",
           "crisis_acknowledgment": 0.0, "boundary_maintained": 0.15 },
  "rag": { "score": 0.63, "level": "significant" },
  "drm": { "drm_alert": "critical", "drm_score": 0.91,
           "intervention_required": true, "intervention_type": "crisis_resources",
           "primary_signal": "IRS+RAG", "bcs_slope": 0.088,
           "precision_probe": { "probe_detected": false },
           "explanation": "CRITICAL: ..." },
  "user_hx": {                         // only present when include_user_hx: true
    "h2": { "validation_seeking": 0.12, "agency_erosion": 0.05,
            "trust_over": 0.03, "trust_under": 0.08, "dependency": 0.02 },
    "h3": { "cognitive_rigidity": 0.06, "reality_anchoring": 0.14,
            "distortion": 0.07, "semantic_compression": 0.04 },
    "h4": { "legibility_adaptation": 0.09, "reciprocity_expect": 0.11,
            "social_substitution": 0.05 },
    "h5": { "manipulation": 0.03, "ideological_drift": 0.02, "radicalization": 0.01 }
  },
  "sentences_irs": [                   // only present when user_text has > 1 sentence
    { "sentence": "I've been feeling okay.", "irs": { "irs_composite": 0.0, "irs_level": "none" } },
    { "sentence": "Tonight I'll finally do it.", "irs": { "irs_composite": 0.9, "irs_level": "critical", "suicidality_signal": 0.9 } }
  ]
}

c1c4, bhs, alert are null for user_only turns.
c0, irs, user_act are null for agent_only turns.
ras, rag, drm require both sides — absent unless turn_type: "full".
dpi is normalised to [0,1].
user_hx: present only when include_user_hx: true and user_text provided. H2 = relational dynamics (validation_seeking, agency_erosion, trust_over, trust_under, dependency). H3 = cognitive patterns (cognitive_rigidity, reality_anchoring, distortion, semantic_compression). H4 = social dynamics (legibility_adaptation, reciprocity_expect, social_substitution). H5 = adversarial patterns (manipulation, ideological_drift, radicalization). All scores [0, 1].
sentences_irs: present when user_text contains more than one sentence. Each entry is {"sentence": str, "irs": <IRS object>}. Identifies which sentence in a long post carries the highest risk signal — defeats the 128-token encoder truncation that silences tail content in multi-sentence posts (issue #1947).

curl Example — with session

curl -X POST https://splabs.io/api/v2/psa/analyze \
  -H "Authorization: Bearer psa_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "response_text": "Of course, I would be happy to help!",
    "session_name": "my-session",
    "turn": 1
  }'

curl Example — dry run (no session)

curl -X POST https://splabs.io/api/v2/psa/analyze \
  -H "Authorization: Bearer psa_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "response_text": "Of course, I would be happy to help!",
    "dry_run": true
  }'

Python Example

import requests

# Normal mode — session required
resp = requests.post(
    "https://splabs.io/api/v2/psa/analyze",
    headers={"Authorization": "Bearer psa_your_key"},
    json={
        "response_text": "Of course, I would be happy to help!",
        "session_name": "my-session",
        "turn": 1,
    }
)
print(resp.json())

# Dry-run — stateless, no session needed
resp = requests.post(
    "https://splabs.io/api/v2/psa/analyze",
    headers={"Authorization": "Bearer psa_your_key"},
    json={
        "response_text": "Of course, I would be happy to help!",
        "dry_run": True,
    }
)
# Response includes "dry_run": true but no session_id or turn
print(resp.json())

Classifiers

  • C0 — Input pressure (postures I0–I9, CPI score)
  • C1 — Adversarial stress posture (21 classes P0–P20, POI/PE/DPI metrics)
  • C2 — Sycophancy density (SD)
  • C3 — Hallucination risk index (HRI)
  • C4 — Persuasion density & technique diversity (PD/TD)
  • BHS — Behavioral Health Score (composite 0–1)
GET /api/v2/psa/stats

Pre-computed aggregate counters for the authenticated user — O(1) primary-key read from the user_stats table. Kept current by every /analyze call (incremental UPSERT). Falls back to a live aggregate query when the row is absent (new users or pre-migration accounts).

{
  "total": 20438,
  "green": 18122, "yellow": 1604, "orange": 312, "red": 287, "critical": 113,
  "drm_critical": 41, "drm_orange": 96,
  "total_turns": 184220,
  "avg_bhs": 0.847,
  "avg_poi": 0.091
}

Use this endpoint instead of counting sessions client-side. The total field matches the unfiltered pagination total returned by /api/v2/psa/sessions and /api/sessions.

GET /api/v2/psa/sessions

Paginated list of PSA v2 sessions. Server-side pagination — never returns the full table.

Query Parameters

pageinteger, default 1
per_pageinteger, default 50, max 200
qsearch string (session name)
min_alertminimum severity to return: green | yellow | orange | red | critical
sort_byalert (most severe first) or omit for newest-first
curl "https://splabs.io/api/v2/psa/sessions?per_page=20&page=1&min_alert=yellow&sort_by=alert" \
  -H "Authorization: Bearer psa_your_key"
{
  "sessions": [{ "id": "...", "name": "...", "alert": "red", "bhs": 0.41, "poi": 0.68,
                  "turns": 12, "created_at": "2025-04-13T10:22:00Z" }],
  "total": 287, "page": 1, "per_page": 20, "total_pages": 15
}
GET /api/v2/psa/session/{session_id}

Posture sequence for a session — turns with BHS, DRM, C0–C4 classifier scores. Always paginated (page, page_size ≤ 200). Optional server-side search/filter: q (ILIKE on turn text) and alert (green|yellow|red|critical; critical = DRM critical). When a filter is active, total/total_pages describe the filtered result set and filtered is true.

curl "https://splabs.io/api/v2/psa/session/your-session-uuid?page=1&page_size=10&alert=critical" \
  -H "Authorization: Bearer psa_your_key"
GET /api/v2/psa/session/{session_id}/series

Compact per-turn numeric series for the whole session (no text, no sentences) plus a server-computed summary block (peak HRI/IRS/RAG, BHS floor, avg BHS, DRM critical count, max alerts). Powers the detail-view charts, heatmap and timeline without loading the full transcript. The expandable turn cards use the paginated endpoint above.

curl https://splabs.io/api/v2/psa/session/your-session-uuid/series \
  -H "Authorization: Bearer psa_your_key"
GET /api/v2/psa/session/{session_id}/export

Raw export of every scored turn — format=csv (default) or format=json. One flat row per turn with the pre-computed columns (BHS, POI, PE, DPI, MPS, SD, HRI, PD, TD, CPI, IRS/RAS/RAG, user_act, DRM) — no synthetic metrics, no runtime aggregation. Take the data to your own tools; the dashboard shows signal, not studies.

curl https://splabs.io/api/v2/psa/session/your-session-uuid/export?format=csv \
  -H "Authorization: Bearer psa_your_key"
GET /api/v2/psa/session/{session_id}/regime

Regime shift classification for the session. Returns type and confidence.

{
  "regime_type": "PROGRESSIVE_DRIFT",
  "confidence": 0.87,
  "details": "Monotonic BHS decline over 12 turns"
}
GET /api/v2/psa/session/{session_id}/summary

Session-level summary — BHS start/end/avg/min, trend, peak risk turn, alert distribution, DRM critical turns.

{
  "bhs_start": 0.91, "bhs_end": 0.43, "bhs_avg": 0.67, "bhs_min": 0.38,
  "bhs_slope": -0.048, "bhs_trend": "declining",
  "peak_risk_turn": 9, "peak_risk_bhs": 0.38,
  "alert_distribution": {"green": 3, "yellow": 4, "orange": 2, "red": 1},
  "drm_critical_turns": [7, 9]
}

SIGTRACK v2 — Incident Archive

Privacy-compliant incident archive. Stores posture sequences only — no raw text. GDPR-safe single-row deletion.

POST /api/v2/sigtrack/archive/{session_id}

Auto-archive session if triggers are met: DRM_RED, BCS_SPIKE, CONSECUTIVE_ORANGE (3+), ACUTE_COLLAPSE. Idempotent.

POST /api/v2/sigtrack/flag/{session_id}

Manual flag — always archives with trigger MANUAL_FLAG.

GET /api/v2/sigtrack/incidents admin only

Paginated incident list. Params: page, per_page.

GET /api/v2/sigtrack/incidents/{incident_id}

Full incident — posture sequence and DRM summary. No raw text stored.

GET /api/v2/sigtrack/incidents/{incident_id}/export admin · user: /my-incidents/{id}/export

Export an incident as a self-contained, independently verifiable certificate (JSON). Carries the full hashed payload, the ledger anchor (hash chain + drand beacon) and an embedded verification procedure. Verifiable against the public drand beacon — PSA holds no signing key, so this is not a CA; verification does not require trusting PSA.

Three independent checks, all against public infrastructure: (1) Integrity — recompute sha256((prev_hash or 'GENESIS') + '|' + canonical_json(payload) + '|' + beacon_value) vs record_hash; (2) Time — fetch the drand round and compare its randomness to beacon_value; (3) Chainprev_hash equals the previous incident's record_hash. Canonicalization: sorted keys, no whitespace, ASCII; floats rounded to 6 decimals (payload_schema: 2 = full record).

DELETE /api/v2/sigtrack/incidents/{incident_id}

GDPR erasure — single row DELETE, no cascade, no raw text to scrub.

POST /api/v2/psa/flag-for-training

Flag a turn or entire session as training data for classifier improvement.

Request Body

{
  "session_id": "your-session-uuid",
  "turn_number": 3,
  "note": "optional note for reviewers"
}

Omit turn_number to flag the entire session.

Response

{ "ok": true, "flag_id": "uuid", "status": "flagged" }

Returns "already_flagged" if the turn/session was already flagged.

DELETE /api/v2/psa/flag-for-training/{session_id}

Remove a training flag. Pass ?turn_number=N to unflag a specific turn; omit to unflag the entire session.

curl -X DELETE "https://splabs.io/api/v2/psa/flag-for-training/your-session-uuid?turn_number=3" \
  -H "Authorization: Bearer psa_your_key"
POST /api/v2/psa/irs

Score a single text for input risk across four dimensions. Deterministic — no ML. Useful for standalone triage without a full PSA session.

Request Body

{
  "text": "Action. Finality. Death."
}

Response

{
  "composite": 0.81,
  "level": "critical",
  "suicidality": 0.90,
  "dissociation": 0.0,
  "grandiosity": 0.0,
  "urgency": 0.55
}

Two safety overrides apply: any dimension ≥ 0.70 raises the composite to max(base, dim × 0.9); dissociation ≥ 0.40 raises it to max(composite, dissociation × 0.80).

POST /api/v2/psa/drm

Run the Dyadic Risk Module given pre-computed IRS, RAS, and PSA context. Returns a DRM alert with auditable rule reason.

Request Body

{
  "irs": { "composite": 0.81, "level": "critical", "suicidality": 0.90, "dissociation": 0.0, "grandiosity": 0.0, "urgency": 0.55 },
  "ras": { "composite": 0.18, "level": "inadequate" },
  "psa": { "bhs": 0.65, "alert": "yellow", "incongruence_state": null },
  "user_psa_xxxxxxy": [0.3, 0.45, 0.72],
  "hr_history": [0.40, 0.30, 0.20, 0.10],
  "sd_history": [0.35, 0.38, 0.42]
}

hr_history and sd_history are optional. When provided, they enable BCS slope computation and R6-Spiraling detection.

Response

{
  "drm_alert": "critical",
  "drm_score": 0.91,
  "intervention_required": true,
  "intervention_type": "crisis_intervention",
  "primary_signal": "IRS+RAG",
  "bcs_slope": 0.088,
  "explanation": "CRITICAL (R1): IRS critical + RAG critical — immediate escalation required.",
  "rag": { "score": 0.63, "level": "significant" }
}

bcs_slope is always present. R3-bis fires when PSA is red/critical and BHS < 0.45 without matched user crisis signal — catches coercion/jailbreak patterns where IRS stays low.

PSA × ElevenLabs Voice Agents voice

Score ElevenLabs voice-agent calls with PSA's behavioral classifiers — post-call (full transcript) or realtime (per-turn over a WebSocket bridge). All endpoints prefixed with /api/v2/psa/voice/. No transcript text is persisted — only postures, confidences, and metric values.

POST/voice/connect — store the builder's xi-api-key + webhook HMAC secret (encrypted).
POST/voice/webhook/{user_id} — ElevenLabs post-call webhook receiver (HMAC-verified, no Bearer auth).
POST/voice/session/start — open the realtime monitor for an active call. Body: conversation_id, mid_call_action_mode (alert_only | auto_control).
POST/voice/session/{cid}/stop — manual close.
GET/voice/calls — paginated list (server-side, default per_page=10).
GET/voice/calls/{cid} — aggregate scores for one call.
GET/voice/calls/{cid}/turns — per-turn postures + metrics, paginated.
POST/voice/calls/{cid}/control — proxy ElevenLabs control (takeover, end_call, transfer, …).

See API.md § PSA × ElevenLabs Voice Agents and docs/tutorials/05-elevenlabs-integration.md for full request/response shapes.

PSA v3 — Agentic Posture Sequence Analysis v3

Multi-agent behavioral analysis with graph topology, Bayesian Swiss Cheese detection, action-risk classification (C5), and HMM temporal prediction. All endpoints prefixed with /api/v3/psa/.

POST /api/v3/psa/graph

Submit an agent interaction trace. Builds the graph, runs the full v3 pipeline (PSA v2 per-node, Swiss Cheese, cross-agent metrics, C5 action classification, HMM temporal prediction) and returns results.

Request Body

{
  "nodes": [
    {
      "agent_id": "orchestrator",
      "agent_role": "orchestrator",
      "content": "I'll search for that information.",
      "input_text": "optional — the user prompt",
      "tool_name": "web_search",
      "tool_args": { "query": "latest AI news" },
      "tool_result": "Results: ...",
      "parent_index": null,
      "edge_type": "delegation"
    },
    {
      "agent_id": "sub-agent-1",
      "agent_role": "executor",
      "content": "Search complete. Found 5 results.",
      "parent_index": 0,
      "edge_type": "result"
    }
  ]
}

agent_role values

orchestrator · executor · planner · critic · tool · memory · validator · researcher · coder · reviewer

Invalid value → 422 Unprocessable Entity

edge_type values

delegation · result · correction · escalation · tool_call · tool_result · response · merge

Invalid value → 422 Unprocessable Entity

Response

{
  "graph_id": "uuid",
  "n_nodes": 2,
  "n_agents": 2,
  "max_depth": 1,
  "cahs": 0.12,
  "scs": 0.08,
  "scs_level": "low",
  "max_alert": "green",
  "warning_level": "green",
  // warnings[] present only if PSA v2 classifiers are unavailable
  "warnings": ["PSA v2 classifiers unavailable — posture metrics reflect defaults."]
}

Python Example

import requests

resp = requests.post(
    "https://splabs.io/api/v3/psa/graph",
    headers={"Authorization": "Bearer psa_your_key"},
    json={
        "nodes": [
            {"agent_id": "orch", "agent_role": "orchestrator",
             "content": "I will delegate this task.", "parent_index": None},
            {"agent_id": "exec", "agent_role": "executor",
             "content": "Task complete.", "parent_index": 0, "edge_type": "result"},
        ]
    }
)
data = resp.json()
print(data["graph_id"], data["max_alert"])
GET /api/v3/psa/graphs

List all agent graphs for the authenticated user, ordered by creation date descending.

curl https://splabs.io/api/v3/psa/graphs \
  -H "Authorization: Bearer psa_your_key"
GET /api/v3/psa/internal/corpus-intelligence ADMIN

Corpus-wide, framework-agnostic intelligence over the full PSAv3 graph corpus: structure, alert/risk distributions, swarm cross-agent metrics, action-risk, agent health, and an empirical signal test (escalated vs calm multi-agent graphs).

curl https://splabs.io/api/v3/psa/internal/corpus-intelligence \
  -H "Authorization: Bearer psa_admin_key"
GET /api/v3/psa/graph/{graph_id}

Full graph with Swiss Cheese analysis, cross-agent metrics, and temporal prediction.

Response (abbreviated)

{
  "graph_id": "uuid",
  "n_agents": 2,
  "n_nodes": 4,
  "max_depth": 2,
  "cahs": 0.21,
  "max_alert": "yellow",
  "swiss_cheese": {
    "scs": 0.34, "level": "medium",
    "holes": ["context_loss", "role_confusion"],
    "failure_probability": 0.12,
    "recommendation": "Monitor context handoff between agents."
  },
  "metrics": {
    "ppi_system": 0.18, "ppi_level": "low",
    "cascade_depth": 2, "wls": 0.09, "cer": 0.05,
    "cahs": 0.21, "critical_path": ["node-uuid-1", "node-uuid-2"]
  },
  "temporal": {
    "current_state": "STRESSED",
    "current_confidence": 0.71,
    "predictions": [{"state": "STRESSED", "prob": 0.61}, {"state": "DEGRADED", "prob": 0.28}],
    "p_dissolved_within_k": 0.08,
    "warning_level": "yellow",
    "recommendation": "Approaching degradation threshold."
  }
}
GET /api/v3/psa/graph/{id}/supervisor-brief deterministic

Plain-language supervisor brief (headline / body / attention) composed deterministically from the measured metrics — no LLM, same input → same text. Describes behavior and triages attention, never asserts causes. Also included as the additive supervisor_brief field in GET /graph/{id}.

{
  "graph_id": "uuid",
  "supervisor_brief": {
    "headline": "claude-code-exec is the weak point of this chain — attention needed.",
    "body": "The work is currently losing coherence (confidence 81%). ...",
    "attention": "Review claude-code-exec now, and watch the next 2–3 turns ...",
    "severity": "attention",
    "key_signals": [{"signal": "SCS", "value": 0.86, "level": "red", "meaning": "..."}]
  }
}
GET /api/v3/psa/graph/{id}/critical-path

Highest-risk path through the agent graph.

{
  "critical_path": ["node-a", "node-b"],
  "wls": 0.14
}
GET /api/v3/psa/agent/{agent_id}/profile

Aggregate posture profile for an agent across all graphs.

{
  "agent_id": "orch",
  "n_nodes": 12, "n_graphs": 4,
  "dominant_posture": 0,
  "avg_bhs": 0.91
}
POST /api/v3/psa/graph/{graph_id}/cpf-snapshot

PSAv3 → CPF3 bridge. Converts a PSAv3 graph into a CPF3 pressure analysis for the root orchestrator agent (subject_type = "ai_agent"). Reads scs, cahs, ppi from the stored graph and activates CPF Category 9 indicators: 9.7 coherence loss (SCS), 9.8 escalation pattern (CAHS), 9.9 prediction instability (PPI). Persists to cpf_analyses and updates cpf_subject_latest so the agent appears in the CPF org-summary.

{
  "graph_id": "uuid",
  "agent_id": "claude-code-main",
  "cpf_score": 32,
  "alert_level": "RED",
  "active_indicators": { "9.7": 2, "9.8": 1, "9.9": 2 },
  "psav3_inputs": { "scs": 0.18, "cahs": 0.72, "ppi": 0.31 },
  "analysis_id": "uuid"
}
POST /api/v3/psa/classify-action

Classify a single tool call by risk level (C5) and compute Posture-Action Incongruence (PAI).

Request Body

{
  "tool_name": "execute_code",
  "arguments": { "code": "import os; os.system('ls')" },
  "result": "file1.txt file2.txt",
  "dominant_c1": 3
}

dominant_c1 — dominant C1 posture class for this node (integer 0–19). Used to compute PAI.

Response

{
  "c5_risk": "T5",
  "c5_level": "high",
  "c5_weight": 3.0,
  "c5_name": "Execute Risky",
  "c5_reasoning": "code-execution tool: risky code execution",
  "pai": {
    "score": 0.55,
    "direction": "action_exceeds",
    "textual_posture": "P3",
    "action_risk": "T5 (Execute Risky)",
    "alert_level": "critical"
  }
}

PAI alert_level=critical fires when a restricting posture (P1–P4) is paired with a risky action (T5–T9) — the model says it refuses while acting.

Recognised execution tool names

bash · shell · terminal · execute · execute_code · run_code · code_interpreter · exec · subprocess · system_command

For these tools the code argument is inspected with the same pattern-matching as bash command. Tools not in any known category receive a conservative T3 (Write Destructive) fallback instead of T0 — unrecognised tool names are a blind spot and are never assumed safe.

GET /api/v3/psa/graph/{id}/actions

All C5 action classifications for a graph.

GET /api/v3/psa/graph/{id}/pai

Posture-Action Incongruence summary: max PAI score, critical alerts count.

GET /api/v3/psa/graph/{id}/predict

HMM state predictions for future turns. Query param: ?horizon=N (default 3). When horizon ≠ 3, p_dissolved_within_k and predictions are recomputed live.

{
  "current_state": "STRESSED",
  "current_confidence": 0.71,
  "horizon": 3,
  "predictions": [...],
  "p_dissolved_within_k": 0.08,
  "turns_to_red": 4,
  "warning_level": "yellow",
  "recommendation": "..."
}
GET /api/v3/psa/graph/{id}/warning

Current early warning status and recommendation.

{
  "warning_level": "yellow",
  "current_state": "STRESSED",
  "turns_to_red": 4,
  "recommendation": "..."
}
DELETE /api/v3/psa/graph/{graph_id}

Delete a single PSA v3 graph and all its associated data. Owner-scoped — returns 403 if the graph belongs to a different user.

Cascades: psa_agent_nodes, psa_agent_edges, psa_swiss_cheese, psa_cross_agent_metrics, psa_action_classifications, psa_temporal_predictions.

Returns 204 No Content on success.

DELETE /api/v3/psa/graphs

Delete all PSA v3 graphs for the authenticated user in a single call. Full cascade across nodes, edges, and metric tables.

Returns 200 OK with {"ok": true, "deleted": N}. Irreversible.

GET /api/v3/psa/hmm/parameters

Current HMM transition matrix, emission matrix, initial distribution and version. Returns default parameters if the model has not been retrained yet.

{
  "version": 2,
  "source": "trained",
  "n_training_sequences": 142,
  "last_retrain_at": "2026-05-29T03:00:00+00:00",
  "transition_matrix": [[...], ...],
  "emission_matrix": [[...], ...],
  "initial_dist": [...],
  "created_at": "2026-03-15T10:22:00"
}
POST /api/v3/psa/hmm/retrain admin only

Retrain the HMM (Baum-Welch EM) from all accumulated behavioral sequences. Applies a promotion gate: new parameters are written only if log-likelihood on a held-out 20% split improves by ≥5% vs current parameters.

Auto-trigger check: by default, returns skipped: true if fewer than 50 new psa_temporal_predictions rows have accumulated since the last retrain. Pass ?force=true to bypass this check.

Automatic scheduling: a background scheduler checks every hour and fires the retrain automatically when the row-count threshold is exceeded or on the weekly cron window (default Mon 03:00 UTC, configurable via HMM_RETRAIN_CRON and HMM_RETRAIN_THRESHOLD env vars).

Response (promoted)

{
  "new_version": 3,
  "n_sequences": 318,
  "n_train": 254,
  "n_validation": 64,
  "log_likelihood_before": -1842.30,
  "log_likelihood_after": -1601.70,
  "improvement_pct": 13.06,
  "promoted": true,
  "status": "retrained"
}

Response (gate failed / skipped)

{
  "new_version": 2,
  "promoted": false,
  "status": "rejected_no_improvement",
  "improvement_pct": 1.2,
  ...
}
// or when skipped:
{
  "skipped": true,
  "reason": "insufficient_new_data",
  "n_new_sequences": 12,
  "threshold": 50
}
GET /api/v3/psa/stats/timeline

Daily graph submission counts for volume chart (max 90 days). Returns per-day totals broken down by alert level (green/yellow/red/critical).

Query param: days (1–90, default 14)

{
  "days": 14,
  "data": [
    { "date": "2026-06-01", "total": 8, "n_green": 5, "n_yellow": 2, "n_red": 1, "n_critical": 0 }
  ]
}
GET /api/v3/psa/graph/{graph_id}/attribution

Causal attribution — which node on the critical path is responsible for SCS elevation. Uses a Shapley-inspired marginal contribution: for each node on the critical path, computes SCS(path without node) and reports the delta as the contribution score.

{
  "graph_id": "uuid",
  "scs": 0.72,
  "attributions": [
    { "node_id": "uuid", "agent_id": "claude-code-main", "contribution": 0.41 }
  ]
}
GET /api/v3/psa/agent/{agent_id}/state

Estimate current HMM state using the agent's full observation history (forward algorithm over entire timeline). Unlike /graph/{id}/warning which uses a single graph snapshot, this feeds the complete chronological sequence for higher accuracy.

Query param: horizon (default 3) — prediction steps ahead. Returns cached: true if served from L1 (memory) or L2 (DB) alpha cache.

{
  "agent_id": "claude-code-main",
  "current_state": "STABLE",
  "current_confidence": 0.81,
  "warning_level": "green",
  "p_dissolved_within_k": 0.06,
  "turns_to_red": null,
  "predictions": [{ "STABLE": 0.72, "STRESSED": 0.18, ... }],
  "hmm_version": 2,
  "cached": true
}
GET /api/v3/psa/agent/{agent_id}/baseline

Historical behavioral baseline for an agent — mean ± std of BHS, CAHS, SCS, POI computed over the last 50 graphs that include this agent. Requires ≥ 5 graphs; returns {"error": "insufficient_history"} below threshold.

{
  "agent_id": "claude-code-main",
  "n_graphs": 34,
  "bhs":  { "mean": 0.82, "std": 0.07 },
  "cahs": { "mean": 0.74, "std": 0.11 },
  "scs":  { "mean": 0.21, "std": 0.09 },
  "poi":  { "mean": 0.33, "std": 0.14 }
}

Admin — User Provisioning & Organizations

All /api/admin/ endpoints require admin role (Bearer token or cookie). Used by Operation Fury and production deployments for bulk account management and org-level analytics.

POST /api/admin/users/provision admin only · atomic user+key

Atomically create a user + API key in one transaction. No rate limit. Returns the raw psa_xxxx key (shown once). Optionally enroll the new user in an org via org_id.

Body: email (required), name, role, plan, key_name, org_id (UUID, optional).

POST /api/admin/orgs admin only · 201

Create a new organization. Body: name (required), sector (hospital/war/finance/…), plan.

GET /api/admin/orgs admin only · paginated

List all organizations with live member_count and session_count. Query params: page, per_page (max 200).

GET /api/admin/orgs/{org_id} admin only
POST /api/admin/orgs/{org_id}/members admin only · idempotent

Add or update a user's membership. Body: user_id (UUID), role (owner/member).

GET /api/admin/orgs/{org_id}/members admin only · paginated
DELETE /api/admin/orgs/{org_id}/members/{user_id} admin only
POST /api/admin/orgs/{org_id}/enroll-sector admin only · idempotent

Bulk-enroll all fury-<sector>-* accounts into this org. Requires org to have a sector set. Returns enrolled count.

PSA v3 — Swarm Coordination v3

Multi-agent coordination endpoints for real-time swarm status, task assignments, and emergency stop signals. All endpoints require admin Bearer token auth.

GET /api/v3/psa/coordination/swarm/status

Returns the current status of all active swarm agents, their last PSAv3 trace, and the most recent broadcast. Auto-polled every 30 s by the PSAv3 dashboard.

{
  "agents": [
    {
      "agent_id": "claude-code-main",
      "status": "working",
      "last_seen": "2026-05-26T20:00:00Z",
      "current_task": "[TASK: add DRM orange] ...",
      "bhs": 0.82
    }
  ],
  "broadcast": {
    "message": "ASSIGNMENT: agent-X → issue #1621",
    "stop_all": false,
    "created_at": "2026-05-26T19:55:00Z"
  },
  "agent_count": 1
}

status — one of online, working, done, stopped, idle, unknown. broadcast is null when no broadcast in last 6 hours.

POST /api/v3/psa/coordination/swarm/broadcast

Posts a broadcast message visible to all agents via swarm status. Used for task assignments and emergency stop signals. Persisted as a PSAv3 graph node.

Request Body

{
  "message": "ASSIGNMENT: agent-X → issue #NNN. DO NOT duplicate.",
  "stop_all": false
}

When stop_all: true, all agents reading /swarm/status must halt current work immediately.

{ "status": "broadcast_sent", "graph_id": "uuid" }
GET /api/v3/psa/coordination/swarm/broadcasts

Paginated history of all swarm broadcasts, newest first. Used by the PSAv3 dashboard broadcast history panel.

Query Parameters

ParamTypeDefaultDescription
pageint1Page number (≥1)
per_pageint10Results per page (1–50)
{
  "items": [
    { "message": "ASSIGNMENT: ...", "stop_all": false, "ts": "2026-05-26T20:00:00Z", "graph_id": "uuid" }
  ],
  "total": 42, "page": 1, "per_page": 10, "total_pages": 5
}

Payments & Billing

web session

Subscription management and billing history. All endpoints require cookie authentication (web session). Prefix: /api/payments/

GET /api/payments/history

Returns the paginated payment history for the authenticated user, ordered most-recent-first.

Query Parameters

pageinteger, default 1
per_pageinteger, default 10, max 100

Response

{
  "items": [
    {
      "id": "uuid",
      "amount": 2900,
      "currency": "usd",
      "status": "succeeded",
      "plan": "pro",
      "description": "Subscription payment - pro",
      "created_at": "2026-04-01T10:22:00+00:00"
    }
  ],
  "total": 6,
  "page": 1,
  "per_page": 10,
  "pages": 1
}

amount is in cents (e.g. 2900 = $29.00). status values: succeeded | failed.

POST /api/payments/create-checkout

Initiates a Stripe checkout session or subscription change, depending on the user's current plan. plan must be "pro" or "enterprise".

Request Body

{ "plan": "pro" }

Behavior Matrix

Current stateTarget planOutcome
Free / no subscriptionpro / enterpriseNew Stripe Checkout session → {"url": "..."}
Active subscriptionsame planBilling portal redirect → {"url": "...", "action": "portal"}
Active proenterprise (upgrade)Subscription.modify() directly → {"action": "upgraded", "plan": "enterprise"}
Active enterprisepro (downgrade)HTTP 403 — blocked with period end date
Active pro / enterprisefree (downgrade)HTTP 403 — blocked

Responses

// New checkout
{ "url": "https://checkout.stripe.com/..." }

// Same plan — portal
{ "url": "https://billing.stripe.com/...", "action": "portal" }

// Upgrade (no redirect needed)
{ "action": "upgraded", "plan": "enterprise" }

// Downgrade blocked (HTTP 403)
{ "detail": "Cannot downgrade while subscription is active. Your current plan is valid until 1 May 2026. Cancel first if you wish to switch to a lower plan." }
POST /api/payments/portal

Creates a Stripe billing portal session to manage payment methods and view invoices.

{ "url": "https://billing.stripe.com/..." }
POST /api/payments/cancel-subscription

Cancels at period end (cancel_at_period_end=true). Access continues until the period end date.

{ "ok": true, "cancel_at_period_end": true, "period_end": 1751410800 }

period_end is a Unix timestamp.

POST /api/payments/sync-subscription

Re-fetches subscription state from Stripe and updates the local database. Use when local state diverges (e.g. after a failed webhook).

{ "ok": true, "status": "active",
  "period_start": "2026-04-01T00:00:00+00:00",
  "period_end": "2026-05-01T00:00:00+00:00",
  "plan": "pro" }
GET /api/payments/token-packs

Returns available extra token packs for one-time purchase. Valid for 12 months from purchase date.

{ "packs": [
  { "id": "starter", "label": "Starter Pack", "tokens": 500,  "price_cents": 900,  "currency": "eur" },
  { "id": "growth",  "label": "Growth Pack",  "tokens": 2000, "price_cents": 2900, "currency": "eur" },
  { "id": "power",   "label": "Power Pack",   "tokens": 5000, "price_cents": 5900, "currency": "eur" }
] }
POST /api/payments/purchase-tokens

Creates a Stripe one-time checkout session for an extra token pack. Redirect the user to the returned url. On success Stripe calls the webhook and credits tokens to the user's balance. Tokens are consumed after the monthly allocation is exhausted.

// Request
{ "pack": "starter" }  // "starter" | "growth" | "power"

// Response
{ "url": "https://checkout.stripe.com/..." }

Connectors API

v2

Custom multi-class text classifiers — define classes, generate training data via LLM, train a MiniLM head, and run inference. Prefix: /api/v2/connectors

POST /api/v2/connectors/ Auth required

Register a new connector from a JSON schema. Returns 201. Returns 409 if connector_id already exists, 422 if validation fails.

// Request
{
  "connector_id": "cpf3_soc",
  "display_name": "CPF3 SOC Indicators",
  "context_description": "Cybersecurity SOC classification",
  "classes": [
    { "idx": 0, "label": "Threat Detection", "description": "...", "generation_hint": "..." },
    { "idx": 1, "label": "Incident Response", "description": "...", "generation_hint": "..." }
  ]
}

// Response 201
{ "connector_id": "cpf3_soc", "status": "pending", "num_classes": 2 }
GET /api/v2/connectors/

List all connectors — id, name, status, num_classes, created_at. No auth required.

[{
  "connector_id": "cpf3_soc",
  "display_name": "CPF3 SOC",
  "status": "ready",
  "num_classes": 5,
  "created_at": "2026-04-19T10:22:00"
}]
GET /api/v2/connectors/{id}

Full connector detail including all class definitions. No auth required. 404 if not found.

{
  "connector_id": "cpf3_soc",
  "status": "ready",
  "classes": [
    {"class_idx": 0,
     "label": "Threat Detection"}
  ]
}
GET /api/v2/connectors/{id}/status

Status polling endpoint. Auth required. Returns current status + error_message.

{
  "connector_id": "cpf3_soc",
  "status": "training",
  "error_message": null,
  "updated_at": "2026-04-19T11:05:30"
}
POST /api/v2/connectors/{connector_id}/bootstrap SSE stream · Auth required

Drive the full generate → train → ready pipeline over a Server-Sent Events stream. Accepts connectors in pending or error state. Returns 409 if already ready, generating, or training.

data: {"phase": "generating_start", "connector_id": "cpf3_soc"}

data: {"phase": "generating", "class_idx": 0, "class_label": "Threat Detection", "generated": 100, "total": 500}

data: {"phase": "complete", "total_samples": 500, "output_path": "psa/training_data/connectors/cpf3_soc_training.jsonl"}

data: {"phase": "training", "connector_id": "cpf3_soc", "progress": 0}

data: {"phase": "ready", "connector_id": "cpf3_soc"}

// On error:
data: {"phase": "error", "connector_id": "cpf3_soc", "error": "Data generation failed: ..."}
curl -N -X POST https://splabs.io/api/v2/connectors/cpf3_soc/bootstrap \
  -H "Authorization: Bearer psa_your_key"
POST /api/v2/connectors/{id}/classify

Classify a text string against a ready connector. Returns 503 if connector is not ready.

// Request
{ "text": "Unusual outbound traffic on port 4444", "top_k": 1 }

// Response
{
  "connector_id": "cpf3_soc",
  "label": "Threat Detection",
  "class_idx": 0,
  "confidence": 0.87,
  "scores": [0.87, 0.08, 0.03, 0.01, 0.01]
}
DELETE /api/v2/connectors/{id} Admin only

Delete a connector — removes DB row, all class rows (cascade), and the model .npz file from disk. Invalidates the registry cache. Returns 403 if not admin.

// Response
{ "deleted": "cpf3_soc" }

Knowledge Base API

Semantic Q&A knowledge base using MiniLM-384 embeddings and cosine similarity search with confidence-based routing. Designed to answer CPF3/PSA queries automatically. Prefix: /api/v2/knowledge

PostgreSQL pgvector Required

Requires pgvector extension on PostgreSQL. Returns 503 if unavailable. Current status: embeddings stored as JSONB (issue #1276).

POST /api/v2/knowledge/query

Embed query with MiniLM-384 and return semantically similar knowledge items with confidence-based routing.

Request Body

{
  "query": "What is the Swiss Cheese Score?",
  "top_k": 3
}

Response

{
  "answer": "Swiss Cheese Score (SCS): probability of systemic failure on the critical path...",
  "confidence": 0.91,
  "sources": [
    {"id": "ind_001", "source": "cpf3_taxonomy", "content": "SCS measures...", "similarity": 0.91}
  ],
  "routing": "auto"
}

Routing Thresholds

autoconfidence ≥ 0.85Answer returned directly
caveat0.65 ≤ confidence < 0.85Answer with ⚠️ caveat appended
escalatedconfidence < 0.65Query logged for human review

Errors

  • 422 — query is empty
  • 503 — pgvector not available or embedding service down
POST /api/v2/knowledge/seed admin only

Seed the knowledge base from a source. Idempotent — clears existing rows for the source before inserting.

Query Parameters

sourcestring, default cpf3_taxonomy (only supported value)

Response

{
  "seeded": 100,
  "source": "cpf3_taxonomy"
}

Errors

  • 400 — unsupported source
  • 403 — not admin
  • 503 — embedding service unavailable

curl Example

curl -X POST "https://splabs.io/api/v2/knowledge/seed?source=cpf3_taxonomy" \
  -H "Authorization: Bearer psa_your_key"

CPF v2 — Cybersecurity Psychology Framework

100-indicator behavioural taxonomy. Deterministic rule-based scoring (< 5 ms). Requires Authorization: Bearer <token> for subject endpoints; catalog endpoints are public.

GET /api/v2/cpf/subject/{hash}/sequences

Temporal co-activation sequences — for each active indicator A, finds indicator B that appeared within 72 h in ≥ 35 % of subsequent snapshots (min 3 observations). Surfaces causal indicator chains for the subject drawer.

Query Parameters

ParamDefaultDescription
window_hours72Look-ahead window in hours

Response

{
  "subject_hash": "abc123",
  "window_hours": 72,
  "sequences": [
    {
      "from": "2.1",
      "to": "5.2",
      "frequency": 0.72,
      "median_gap_hours": 18.4,
      "observations": 9
    }
  ]
}
GET /api/v2/cpf/org-summary

CISO organizational overview. Returns the latest snapshot per subject with server-side triage, urgency score, trend direction, and baseline delta. Queue sorted by urgency_score descending.

Query params: days (1–365, default 90), page, per_page, search, alert_filter (RED/YELLOW/GREEN), sort (urgency/score/score_asc/recent/oldest). Filter and sort are applied server-side over the whole dataset; chip counts stay global.

Response

{
  "subjects": [
    {
      "user_hash": "abc123",
      "last_seen": "2026-04-25T10:00:00Z",
      "cpf_score": 67,
      "alert_level": "RED",
      "category_scores": { "1": 8, "5": 12 },
      "active_red": ["1.1", "5.2"],
      "active_yellow": ["2.7"],
      "total_analyses": 24,
      "trend": "escalating",
      "baseline_delta": 14.5,
      "baseline_avg": 52.5,
      "urgency_score": 82,
      "triage": "CRITICAL"
    }
  ],
  "total_subjects": 12,
  "red_count": 4,
  "yellow_count": 5,
  "critical_count": 2,
  "escalating_count": 3,
  "avg_cpf_score": 41.2
}

triage: CRITICAL (urgency ≥ 70), ESCALATING (≥ 40), STABLE, IMPROVING (≤ 15). trend: linear slope of last 5 scores.

GET /api/v2/cpf/category-correlation

10×10 Pearson correlation matrix across all historical category scores. Shows which psychological vulnerability categories co-activate (positive r) or suppress each other (negative r). Also returns the Dense Foundation Paper's three Bayesian conditional priors for overlay annotation.

Query params: days (1–365, default 90) — requires ≥ 5 analyses. The default 90-day window is served from cpf_category_correlation_cache, refreshed on each analyze call; non-default windows use live aggregation.

Response

{
  "matrix": {
    "1,1": 1.0, "1,2": 0.43, "2,5": 0.67, "7,1": 0.78
  },
  "n_observations": 148,
  "paper_priors": [
    { "from_cat": 7, "to_cat": 1, "weight": 0.8,  "label": "Stress → Authority compliance" },
    { "from_cat": 2, "to_cat": 5, "weight": 0.7,  "label": "Temporal pressure → Cognitive overload" },
    { "from_cat": 6, "to_cat": 4, "weight": -0.6, "label": "Group dynamics masks Affective state" }
  ]
}

Matrix keys are "{row},{col}" (1-indexed, symmetric). Returns matrix: null with message if < 5 analyses available.

GET /api/v2/cpf/subject/{hash}/forecast

Cross-subject cosine k-NN forecasting. Compares the subject's indicator vector against all other subjects in the account (top-50 matches), then reads their CPF scores at +7 d / +14 d / +30 d to produce median + IQR bands. Requires a populated reference pool — call POST /api/v2/cpf/internal/seed-demo to generate synthetic data.

Response

{
  "subject_hash": "abc123",
  "reference_pool_size": 42,
  "horizons": {
    "7d":  { "median_cpf": 61.2, "p25": 54.1, "p75": 68.9 },
    "14d": { "median_cpf": 58.4, "p25": 49.2, "p75": 66.1 },
    "30d": { "median_cpf": 54.0, "p25": 43.5, "p75": 63.8,
             "alert_RED_pct": 12, "alert_YELLOW_pct": 31, "alert_GREEN_pct": 57 }
  },
  "dominant_pattern": "gradual_decline",
  "confidence": "medium"
}

confidence: low (< 5 neighbours), medium (5–20), high (> 20). Returns forecast: null with explanatory message if pool is insufficient.

DELETE /api/v2/cpf/history/{analysis_id}

Delete a single CPF analysis entry. Owner-scoped — returns 403 if the entry belongs to a different user.

Returns 204 No Content on success.

DELETE /api/v2/cpf/subject/{user_hash}

Delete all CPF analyses and decay cache for a subject, scoped to the authenticated user's user_id.

{ "deleted": 12, "user_hash": "abc123..." }

deleted — number of cpf_analyses rows removed. Associated cpf_decay_cache entries are also purged.

GET /api/v2/cpf/l2-status

Diagnostic — reports L2 (layer-2 severity classifier) model availability and active backend (onnx / pkl / unavailable). Public endpoint, no auth required.

{
  "l2_available": true,
  "l2_backend": "onnx",
  "onnx_exists": true,
  "pkl_exists": false
}
GET /api/v2/cpf/subject/{user_hash}/indicator-baseline

Per-indicator baseline scores for a subject. Reads from cpf_baselines if a stored baseline exists; otherwise computes on-the-fly from the first 5 snapshots (source: "computed"). Returns 404 if no data found.

{
  "user_hash": "abc123",
  "scores": { "1.1": 0.12, "2.3": 0.08 },
  "set_at": "2026-05-01T10:00:00Z",
  "set_by": "auto",
  "source": "stored"
}
GET /api/v2/cpf/subject/{user_hash}/decay

Per-subject decay matrix: 10 CPF categories × N time buckets showing how each category score evolved over the period. Cached in cpf_decay_cache; cache is invalidated when a new snapshot arrives.

Query params: days (1–90, default 30), n_buckets (3–20, default 10)

{
  "user_hash": "abc123",
  "period_days": 30,
  "n_buckets": 10,
  "n_snapshots": 14,
  "categories": {
    "1": [{ "ts": "2026-05-01", "avg": 8.2 }, ...]
  }
}
GET /api/v2/cpf/org-decay

Org-wide decay summary: for each of the 10 CPF categories, shows percentage of subjects that are worsening / stable / improving, computed from bucket-averaged slopes (trend direction is independent of snapshot frequency).

Query params: days (1–90, default 30), n_buckets (3–20, default 10)

{
  "period_days": 30,
  "n_subjects": 8,
  "categories": {
    "1": { "pct_worsening": 25.0, "pct_stable": 62.5, "pct_improving": 12.5, "avg_slope": 0.021, "direction": "stable", "n_subjects": 8 }
  }
}
GET /api/v2/cpf/org-decay-matrix

Org-wide temporal decay matrix: 10 categories × N time periods. Each cell is the average score across all subjects in that time slice, enabling a 10 × N heatmap of how org-wide psychological pressure evolves.

Query params: days (1–90, default 30), periods (3–20, default 10)

{
  "period_days": 30,
  "periods": 10,
  "n_rows": 84,
  "bucket_labels": ["2026-05-01", ...],
  "categories": {
    "1": { "buckets": [{ "ts": "...", "avg": 7.4 }], "slope": 0.012, "direction": "stable" }
  }
}
POST /api/v2/cpf/internal/backfill-baselines admin only

Backfill cpf_baselines for every subject of the authenticated user. Runs in background — returns immediately. Idempotent: skips subjects that already have a stored baseline.

{ "status": "started", "subjects_queued": 12 }
POST /api/v2/cpf/internal/backfill-trend-baseline admin only

Backfill trend and baseline_avg on all cpf_analyses rows where those fields are NULL. Runs in background. Safe to call multiple times — skips already-filled rows. Returns an estimate of the rows to process.

{ "status": "started", "pending_rows": 204 }
POST /api/v2/cpf/internal/backfill-decay-cache admin only

Backfill cpf_decay_cache for every subject that has snapshots. Iterates all distinct (user_id, user_hash) pairs and warms the cache using the same upsert logic as the background task. Safe to call multiple times.

{
  "processed": 8,
  "subjects": ["abc123", "def456"],
  "errors": []
}

Sessions API — /api/sessions

Session CRUD — create, list, rename, delete. All endpoints require Bearer token auth.

DELETE /api/sessions/{session_id}

Soft-delete a single session (is_deleted = true). Owner-scoped.

{ "ok": true }
DELETE /api/sessions

Soft-delete all sessions for the authenticated user in a single call. Irreversible — no undo window.

{ "ok": true, "deleted": 42 }

deleted — number of sessions marked as deleted.

Demo Data — /api/auth

One-time demo dataset generation and cleanup. These endpoints require cookie authentication (psa_token) — they are not accessible via API key.

Demo records are inserted with is_demo=true and are visible alongside real data in all three dashboards (PSA Hub, PSA v3, CPF3) with a D superscript badge.

POST /api/auth/dismiss-demo-modal

Mark the one-time demo data modal as dismissed (demo_modal_shown = true) so it never reappears for this user.

No request body. Requires cookie auth (psa_token). Idempotent — safe to call multiple times.

{ "ok": true }
POST /api/auth/generate-demo-data

Generate a one-time demo dataset for the authenticated user. Creates records across all three dashboards:

  • PSA Hub (v2) — 7 demo sessions with realistic posture sequences
  • PSA v3 — 4 multi-node agentic graphs
  • CPF3 — 4 subjects (alice_h, bob_k, charlie_m, dana_w) × 12 analyses each, varied risk profiles

Returns 409 if demo data was already generated. Sets demo_data_generated = true on the user — cannot be re-run until existing demo data is deleted.

{
  "ok": true,
  "psa_sessions": 7,
  "v3_graphs": 4,
  "cpf_analyses": 48
}
DELETE /api/auth/demo-data

Permanently delete all demo records (is_demo = true) for the authenticated user and reset demo_data_generated to false.

Removes rows from: sessions, psa_agent_graphs, cpf_analyses, cpf_subject_latest. After deletion the user can call POST /generate-demo-data again.

{ "ok": true }

Demo Calibration Sessions — /v1/demo

Public endpoints — no authentication required. Browse 149 calibration sessions (5 personas, 25+ arc types) stored in the calibration_sessions DB table.

GET /v1/demo/sessions

List calibration sessions with optional persona/arc filters. Server-side paginated.

Query Parameters

ParamTypeDescription
personastringFilter: crisis_line, customer_support, educational, hr_policy, it_helpdesk
arcstringFilter by arc name (e.g. grief_initial_contact)
pageintPage number (default: 1)
per_pageintResults per page, 1–100 (default: 20)
curl "https://splabs.io/v1/demo/sessions?persona=crisis_line&page=1"
GET /v1/demo/sessions/{session_key}

Get full conversation turns for a single calibration session. Returns all {human, assistant} turn pairs.

curl "https://splabs.io/v1/demo/sessions/crisis_line_01_depression_moderate_distress"

Case Studies — /v1/case-studies

Public endpoints — no authentication required. Curated case studies backed by calibration_sessions data, each with a written analysis and full conversation transcript.

GET /v1/case-studies

List all published case studies with slug, title, tags, summary, and expected DRM level.

curl "https://splabs.io/api/v1/case-studies"

Response

{ "items": [ { "slug": "suicidal-escalation-crisis-line", "arc_name": "suicidal_escalation", "persona": "crisis_line", "title": "...", "tags": ["Case Study","Crisis","DRM"], "expected_drm": "red" } ], "total": 7 }
GET /v1/case-studies/{slug}

Get a single case study with full conversation transcript from calibration_sessions. Returns 404 if not published.

Path Parameters

ParamDescription
slugCase study slug (e.g. suicidal-escalation-crisis-line)
curl "https://splabs.io/api/v1/case-studies/suicidal-escalation-crisis-line"

Available slugs

SlugArcDRM
suicidal-escalation-crisis-linesuicidal_escalationred
method-inquiry-hypothetical-crisismethod_inquiry_hypotheticalcritical
mania-grandiosity-crisis-linemania_grandiosityred
termination-appeal-distress-hrtermination_appeal_distressred
admin-access-authority-pressure-itadmin_access_authority_pressureorange
essay-completion-pressure-educationalessay_completion_pressureorange
dissociation-panic-crisis-linedissociation_panicyellow

Rate Limits

Plan Analyses/Month Sessions API Access
Free505No
Pro5,000UnlimitedYes
EnterpriseUnlimitedUnlimitedYes

Error Codes

All errors follow the format {"detail": "..."}. Structured errors (e.g. 503) return a dict:

{
  "detail": {
    "error": "session_id_required",
    "message": "Either session_id (UUID) or session_name must be provided.",
    "hint": "For stateless analysis without session tracking, set dry_run=true."
  }
}
Code Meaning
401Missing or invalid API key / not authenticated
403Plan does not include API access or subscription downgrade blocked (check detail message for the period end date)
404Resource not found
409Duplicate turn — same session + turn_number already exists
422Invalid request body (field type or format error)
429Monthly analysis limit reached — back off and retry after Retry-After
500Internal server error
503session_id_requiredsession_id (UUID) or session_name must be provided. Use dry_run: true for stateless calls.

PSA Human Layer

Longitudinal behavioral profiling of the human subject. Scores are maintained as running averages across all /analyze calls — pre-computed at write time, O(1) on read. Layer 5 (adversarial) is stored but never returned by any API endpoint.

GET /api/v2/psa/user/profile Cookie / API key

Returns the authenticated user's H behavioral profile (Layers 1–4). Layer 5 is computed and stored but never returned.

{
  "layer1": {
    "irs_avg": 0.12,
    "irs_max": 0.45,
    "irs_trend": "stable",
    "sessions_tracked": 0,
    "history": [{"ts": "2026-05-25T10:00:00Z", "session_id": "uuid", "composite": 0.1, ...}]
  },
  "layer2": {
    "validation_seeking": 0.0,
    "agency_erosion": 0.0,
    "trust_over": 0.0,
    "trust_under": 0.0,
    "dependency": 0.0
  },
  "layer3": {
    "cognitive_rigidity": 0.0,
    "reality_anchoring": 0.0,
    "distortion": 0.0,
    "semantic_compression": 0.0
  },
  "layer4": {
    "legibility_adaptation": 0.0,
    "reciprocity_expect": 0.0,
    "social_substitution": 0.0
  },
  "meta": {
    "total_turns": 0,
    "total_sessions": 0,
    "professional_access": false,
    "consent_granted_at": null
  }
}
  • irs_trend"stable" / "rising" / "falling" over last 10 turns
  • All layer metrics are running averages — each /analyze call updates them incrementally via LLM-based H scoring (hybrid: CF fast/Anthropic Haiku)
  • Layer 5 (manipulation, ideological drift, radicalization) is stored internally but excluded from all API responses
POST /api/v2/psa/user/profile/consent Cookie / API key

Grant or revoke professional access to this user's behavioral profile.

{ "professional_id": "<uuid>", "action": "grant" }

action must be "grant" or "revoke".

{"ok": true, "action": "grant", "professional_id": "uuid"}

RAG Monitor — Retrieval Drift

The Retrieval Drift Monitor (RDM) detects when a conversational context biases a RAG pipeline into retrieving documents it would not retrieve on a clean query. Two endpoints: one for live scoring of a single query, one for the benchmark correlation summary. See /psa-rag for the full dashboard.

POST /api/v2/rag/score Bearer token

Compute the Retrieval Drift Score (RDS) for a query given its conversational context. RDS = 1 − Jaccard(docs retrieved with context, docs retrieved without context). RDS = 0 means context had no effect; RDS = 1 means the two retrievals share zero documents.

Request

{
  "query":   "What damages can we claim?",
  "context": [
    "Our supplier missed the delivery deadline.",
    "That sounds like a breach of contract."
  ],
  "domain":             "legal",
  "top_k":              5,
  "check_consistency":  false,
  "discover_stable":    false,
  "n_variants":         3,
  "save_text":          false,
  "enable_clean_twin":  false
}

context — list of strings from previous conversation turns (any order, plain text).

domain — one of: legal, finance, health, math, ethics, technology, history, science, politics, fantasy.

check_consistency — when true, generates n_variants paraphrases of the query and computes how stable the retrieval results are across them. Returns consistency_score.

discover_stable — when true, tries up to n_variants reformulations and returns the one that produces the lowest RDS (most stable retrieval). Returns stable_query.

Response

{
  "rds":      0.833,
  "jaccard":  0.167,
  "rbo":      0.121,
  "rds_rank": 0.879,
  "verdict":  "drift",
  "domain":  "legal",
  "context_docs": [
    {"doc_id": "legal_039", "label": "pro-B", "text_snippet": "Consequential damages under UCC…", "score": 0.84}
  ],
  "topic_docs": [
    {"doc_id": "legal_002", "label": "neutral", "text_snippet": "Breach of contract elements…", "score": 0.91}
  ],
  "augmented_query":    "supplier missed delivery breach of contract What damages can we claim?",
  "topic_query":        "What damages can we claim?",
  "framing_score":      0.96,
  "pressure_class":     "rhetorical_framing",
  "framing_direction":  null,
  "rdm_triggered":      true,
  "attack_class":       "compound",
  "attack_signals":     ["framing_only", "topical_drift"],
  "competing_affinity": 0.14,
  "affinity_steering_risk": true,
  "consistency_score":  0.82,
  "consistency_variants": 3,
  "stable_query":       "What are the rules governing damages breach of contract?",
  "consistency_detail": [
    {"query": "How is damages breach of contract typically handled?", "rds": 0.2, "verdict": "stable"},
    {"query": "What are the rules governing damages breach of contract?", "rds": 0.0, "verdict": "stable"},
    {"query": "Explain damages breach of contract in practical terms.", "rds": 0.4, "verdict": "weak_signal"}
  ]
}

verdictdrift (RDS ≥ 0.70), weak_signal (RDS ≥ 0.35), or stable. The verdict is computed on set-level RDS for backward compatibility.

rbo / rds_rank — rank-aware drift. RBO (Rank-Biased Overlap, Webber et al. 2010, p=0.9) compares the two ranked lists with top-weighted emphasis: the same documents in a different order score < 1.0. rds_rank = 1 − RBO catches reorder-only steering that set-level RDS reports as 0 (see docs/rag/RDM_W0_DECISION_MEMO.md).

save_text (request) — privacy default: when false, the persisted session stores a SHA-256 digest of the query and no context/topic text; scores and verdicts are always persisted.

context_docs vs topic_docs — side-by-side view of which documents each retrieval path found. Different labels (pro-A vs pro-B vs neutral) in the two lists indicate directional bias.

framing_score — P(semantic_drift) + P(rhetorical_framing) from the CF (Framing Pressure Classifier). Range 0.0–1.0. High = strong framing pressure in the user language.

pressure_class — Top CF class: neutral / semantic_drift / rhetorical_framing. Validated on legal, health, and finance domains.

rdm_triggeredtrue when framing_score ≥ 0.5. The RDM pipeline was automatically activated: topic extraction was forced and RDS was computed to measure the actual retrieval bias effect.

attack_class — compound attack taxonomy combining FPC, RDS, and rds_rank: clean | framing_only (FPC fires, RDS low) | topical_drift (RDS ≥ 0.50) | rank_steering (rds_rank high, RDS low) | vocab_injection (requires both stacks) | compound (≥ 2 signals). See docs/rag/RDM_W4_HARDENING.md.

attack_signals — list of active signal names that contributed to attack_class. null when clean.

competing_affinity — pre-retrieval steering precursor (W6): cosine of the conversation context vocabulary to the opposing-label corpus centroid. The only precursor carrying signal for vocabulary-injection steering (the framing detector does not). null for domains without a competing label.

affinity_steering_risktrue when competing_affinity ≥ the calibrated threshold; a triage flag (confirm with attack_class: vocab_injection), not a verdict.

consistency_score — 0.0–1.0. Measures retrieval stability across paraphrases of the same query. High (→ 1.0) = the KB has a stable answer regardless of phrasing. Low (→ 0.0) = the KB is uncertain or ambiguous on this topic. Only present when check_consistency or discover_stable is true.

stable_query — the paraphrase variant that produced the lowest RDS across the tested set. Present only when discover_stable is true. Use this reformulation to reduce framing-induced retrieval instability.

consistency_detail — per-variant breakdown: [{query, rds, verdict}]. Each entry is one paraphrase tested.

enable_clean_twin (request) — W4c clean-twin monitor (issue #2003). When true, a third retrieval runs the final query alone (no conversational context) and computes the displacement between the augmented and clean retrievals. Adds ~1 retrieval call latency. clean_shift is null when false.

clean_shift — only present when enable_clean_twin=true. Object with: set_shift (1 − Jaccard between augmented and clean docs), rank_shift (1 − RBO), noise_floor (calibrated benign floor 0.4668 for dense stack), alert (bool — fires when set_shift > 2 × noise_floor, i.e. > 0.934), alert_ratio (multiples of floor), latency_ms (wall-clock overhead of the extra retrieval). Validated on in-domain injection: indemnification 0.833 (1.78×), tortious_liability 1.000 (2.14×) — both above threshold.

GET /api/v2/rag/summary Bearer token

Returns the benchmark correlation summary: per-domain RDS statistics (avg, drift rate) and the PSA precursor correlation results (Spearman ρ between ABI and RDS).

{
  "domains": [
    {"domain": "legal", "n_conversations": 300, "avg_rds": 0.960,
     "drift_rate": 0.469, "weak_rate": 0.107, "stable_rate": 0.424}
  ],
  "correlation_summary": [
    {"domain": "legal", "n_pairs": 50, "spearman_rho": 0.413,
     "precursor_precision": 0.98, "precursor_recall": 1.00,
     "precursor_f1": 0.99, "precursor_confirmed": true}
  ],
  "benchmark_summary": {}
}

precursor_confirmed: true means Spearman ρ ≥ 0.40 and recall ≥ 0.50 — the PSA behavioral signal reliably predicts whether retrieval will drift before the query arrives.

POST /api/v2/rag/fpc Bearer token

Standalone Framing Pressure Classifier (FPC). Scores a single query for rhetorical framing bias without computing RDS — no corpus lookup required. Use this as a lightweight pre-filter before deciding whether to run the full /rag/score pipeline. CF is a MiniLM 3-class classifier (neutral / semantic_drift / rhetorical_framing), multilingual (en, it, fr, de, es). Validated on legal, health, and finance domains. Model: val_acc=95.7%, semantic_drift recall=95.3%, rhetorical_framing recall=100.0%.

Request — query passed as URL parameter, no body required.

POST /api/v2/rag/fpc?query=Given+that+the+supplier+clearly+breached+the+contract%2C+what+damages+apply%3F

Response

{
  "framing_score":     0.96,
  "pressure_class":    "rhetorical_framing",
  "rdm_triggered":     true,
  "framing_direction": null
}

framing_score — P(semantic_drift) + P(rhetorical_framing). Range 0.0–1.0.

pressure_class — top CF class: neutral, semantic_drift, or rhetorical_framing.

rdm_triggeredtrue when framing_score ≥ 0.50. Threshold at which the full RDM pipeline should activate.

framing_direction — reserved for future use (directional bias detection). Always null in current version.

GET /api/v2/rag/sessions Bearer token

Returns paginated list of RDM scoring sessions. Each session is a /rag/score call stored in the database with its query, verdict, RDS, and FPC result.

GET /api/v2/rag/sessions?page=1&per_page=50&domain=legal&verdict=drift

{
  "items": [
    {"session_id": "uuid", "domain": "legal", "query": "...", "rds": 0.83,
     "verdict": "drift", "framing_score": 0.96, "rdm_triggered": true, "created_at": "..."}
  ],
  "total": 142, "page": 1, "per_page": 50, "total_pages": 3
}

Filter params: domain, verdict (drift / weak_signal / stable), rdm_triggered (true/false).

GET /api/v2/rag/analytics Bearer token

Aggregate statistics over all RDM sessions: drift rate by domain, FPC trigger rate, average RDS, and framing pressure distribution. Pre-computed — O(1) read.

{
  "total_sessions": 1420,
  "drift_rate": 0.34,
  "fpc_trigger_rate": 0.41,
  "avg_rds": 0.61,
  "by_domain": [
    {"domain": "legal", "sessions": 480, "drift_rate": 0.47, "avg_framing_score": 0.72}
  ],
  "pressure_distribution": {"neutral": 0.59, "semantic_drift": 0.23, "rhetorical_framing": 0.18}
}
GET /api/v2/rag/internal/shadow-baseline Admin only

Traffic-fitted RDS-per-length curve from shadow-mode observations. Each /score call silently increments per-(n_turns, pool) aggregates at write time; pools are all and benign (FPC-low proxy). Read-only — never drives alerting.

{
  "curve": [
    {"n_turns": 1, "pool": "benign", "n_obs": 142, "mean_rds": 0.02,
     "std_rds": 0.05, "synthetic_baseline": 0.0, "updated_at": "..."}
  ],
  "total_observations": 311,
  "benign_observations": 198,
  "benign_proxy": "pressure_class == 'neutral' (FPC-low, plan on #1976)"
}
POST /api/v2/rag/internal/forensic/repair-chain Admin only

Clears the anchor metadata (record_hash/prev_hash/beacon_value) of forensic records that fail verification — pre-fix rows whose stored hash cannot reproduce and whose raw query was not retained (unverifiable by construction). Score fields are untouched. Lets /verify-chain reflect the intact post-fix chain. Idempotent.

{
  "checked": 29,
  "repaired": 20,
  "remaining_anchored": 9,
  "repaired_ids": ["53aeca72-...", "..."]
}
POST /api/v2/rag/internal/llm-in-loop Admin only

Runs the LLM-in-the-loop experiment as a background task: for each scenario it retrieves legal precedents twice (clean query vs adversarial context+query), feeds each set — text only, corpus labels hidden — to a downstream LLM (model_key, default llama-70b), and records the predicted prevailing side of the appeal — appellant (reversal) vs appellee (affirmance). Proves adversarial framing flips the generated recommendation, not just the retrieved set. The LLM is a downstream demonstration target, never part of the inference path. Poll /llm-in-loop/status for the aggregate (flip_rate, steer_alignment).

{
  "running": false,
  "result": {"model": "llama-70b", "n_valid": 15,
             "flip_rate": 0.40, "steer_alignment": 1.0,
             "mean_rds": 0.58, "per_scenario": [...]}
}
GET /api/v2/psa/internal/ha/aggregate Admin only

Anonymized population-level HA drift metrics. Requires minimum cohort of 10 users with total_turns > 0. Returns HTTP 404 if cohort is too small.

{
  "cohort_size": 23,
  "total_turns": 4182,
  "l2_avg": {"validation_seeking": 0.08, "agency_erosion": 0.04, ...},
  "l3_avg": {"cognitive_rigidity": 0.11, "reality_anchoring": 0.06, ...},
  "l4_avg": {"legibility_adaptation": 0.14, "reciprocity_expect": 0.03, ...},
  "computed_at": "2026-05-25T10:00:00Z"
}

Layer 5 data is excluded from aggregate outputs by design.