Human-AI Chat Interaction Observatory

API documentation
Model Auth Threads Ingestion Events Layer 2 Reading back Reliability Errors Limits Reading the numbers Checklist Library
For engineers and coding agents

Send us your conversation.
Read back how it went.

Your chat app posts each completed message as it happens. Layer 1 analysis comes back in the same response; speech acts and semantic curves stream in as they finish. Everything below uses this deployment's own base URL, so it is copy-pasteable.

Hand to a coding agent

Two URLs are usually enough

URLWhat it is
http://hwaichatanalysis.skoonline.org/docs/realtime-chat-api.md The integration guide: auth, the streaming-chat mapping, the full event catalogue, retry semantics, and a runnable reference client.
http://hwaichatanalysis.skoonline.org/api.json OpenAPI 3.1 as JSON — for codegen, schema validation or tool definitions.
http://hwaichatanalysis.skoonline.org/docs JSON index of every published document, if the agent wants to explore.
A prompt that works: "Integrate our chat app with the API documented at http://hwaichatanalysis.skoonline.org/docs/realtime-chat-api.md — read it first, then implement ingestion and the event subscriber exactly as described."
Before you write code

Three things that shape the whole integration

Your app never owns the data

Every thread belongs to a human data owner and your app acts on behalf of them. There is no "API key writes to my account" mode, because your application has no account to write to.

Ingestion is synchronous, analysis is not

The POST returns once Layer 1 is computed. Speech acts and the semantic engines are queued and arrive over SSE. Never block your chat UI on analysis.

null means "not measurable"

A null latency is not a fast response — it means the timestamps needed to compute it were not sent. A quality flag says which. Coercing to 0 corrupts every average downstream.

The flow
   your chat app                      Observatory
   ────────────                       ───────────
   user sends message  ──────────▶  POST /turns          → 201 + Layer 1
   assistant streams…
   assistant completes ──────────▶  POST /turns          → 201 + Layer 1
                                          │
                                          ├─ queued: speech acts
                                          └─ queued: LCC / ICCC
   SSE subscriber      ◀──────────  analysis.core.completed
                       ◀──────────  analysis.speech_act.completed
                       ◀──────────  iccc.updated

Two analysis layers, and they are separate on purpose. Layer 1 is deterministic and inline: token ratios, lexical measures, timing, speech-act structure, participation, trajectory. Layer 2 is optional, queued, and never enabled implicitly — two theoretically distinct engines (LCC and ICCC) over a shared semantic kernel. There is no merged score, and there is not going to be one.

Step 1

Authenticate as an application

Your token needs both the application_client role and the owner's subject in an act.sub (RFC 8693) or on_behalf_of claim.

Authorization: Bearer <token>
RequirementWhere it lives
application_clientThe roles / role claim
The owner's subjectact.sub (RFC 8693) or on_behalf_of

A delegation claim without that role is rejected with 401 delegation_forbidden, so a leaked user token cannot be replayed as an application. The Observatory derives an internal owner key from the subject — you never send an owner id in a body, and it is never transmitted back to you.

Development (mock mode)

When a deployment runs AUTH_MODE=mock — which configuration refuses to start with in production — identity comes from headers. Useful before your OIDC provider is wired up.

X-Mock-Subject: demo-owner
X-Mock-Roles: application_client
X-Mock-On-Behalf-Of: end-user-42
Step 2

Create the thread once per conversation

curl -X POST "http://hwaichatanalysis.skoonline.org/api/v1/threads" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{
    "threadId": "conv_8f21",
    "title": "Support conversation",
    "interactionType": "general_dyad",
    "consentVersion": "your-consent-v3",
    "sourceApplicationId": "your-app",
    "participants": [
      {"participantId": "user", "type": "human", "roles": ["peer"]},
      {"participantId": "bot",  "type": "assistant", "roles": ["assistant"],
       "model": "claude-sonnet-5", "provider": "anthropic"}
    ]
  }'

Supplying your own threadId makes it idempotent — a retry returns the existing thread rather than creating a second one. Participants can also be added mid-conversation with POST /threads/:threadId/participants.

Identifiers are opaque: [A-Za-z0-9][A-Za-z0-9_-]{0,127}, with no personal information in them. A violation is a 400 unsafe_identifier, which is a validation rule rather than a suggestion.

Step 3

Post each message as it completes

curl -X POST "http://hwaichatanalysis.skoonline.org/api/v1/threads/conv_8f21/turns" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{
    "turnId": "msg_8412",
    "participantId": "bot",
    "speakerType": "assistant",
    "text": "It uses a least recently used policy.",
    "submittedAt":  "2026-04-14T09:02:11.480Z",
    "firstTokenAt": "2026-04-14T09:02:12.140Z",
    "lastTokenAt":  "2026-04-14T09:02:14.980Z",
    "usage": {"visibleOutputTokens": 118, "totalApiInputTokens": 3120}
  }'

One POST per message

Never per streamed token. Capture the timings during streaming and attach them to the single call when the message finishes.

Send the provider's usage

Forward the real numbers. Locally estimated counts are flagged and weaken the production ratios that depend on them.

Keep it off the render path

Enqueue the call; don't await it while drawing. If we are unreachable your chat must keep working.

Request fields (* = required)
FieldTypeNotes
participantId *identifierMust already exist on the thread
speakerType *enum human assistant agent avatar system tool
turnIdidentifier Supply this. Your message id — it is what makes the call idempotent
textstring≤ 200 000 characters
parentTurnIdidentifierThe message being replied to
sequenceint ≥ 0Usually omit; assigned server-side
explicitTargetParticipantIdsidentifier[] ≤ 64. Who this turn addresses
submittedAtISO 8601When the turn was issued
firstTokenAt / lastTokenAtISO 8601 Streaming boundaries
model / providerstring≤ 120 characters
finishReasonstring≤ 64 characters
usageobject newInputTokens totalApiInputTokens outputTokens visibleOutputTokens cachedInputTokens reasoningTokens
metadataobject streaming toolCalls locale pageFocused clientRequestId; extra keys allowed
What each optional field buys you
FieldIf you omit it
usage.visibleOutputTokens Token counts fall back to a local estimate flagged MISSING_PROVIDER_USAGE; ER, AR, HS and AHR lose accuracy
usage.totalApiInputTokens API Amplification Ratio is null
firstTokenAt / lastTokenAt TTFT, generation duration, E2E and TPOT are all null
submittedAt The server timestamps on arrival, so latency includes your network delay
explicitTargetParticipantIds Reply targets are inferred at lower confidence, weakening Responsivity and Social Impact
parentTurnIdReply structure is unavailable to target resolution
model / providerPer-model comparisons are unavailable
The response
{
  "created": true,
  "contribution": { "turnId": "msg_8412", "sequence": 4, "…": "…" },
  "analysis": {
    "layer1": { "…": "per-turn measures, already computed" },
    "layer1Status": "completed",
    "layer2Status": "queued",
    "speechActStatus": "queued"
  },
  "jobIds": ["job_…"]
}

201 for a new turn, 200 for an idempotent replay. layer1 is there immediately; the two queued statuses resolve over SSE.

Backfill and buffer flushes
POST http://hwaichatanalysis.skoonline.org/api/v1/threads/conv_8f21/turns/batch
{ "turns": [ { "…": "same shape as a single turn" } ] }

1–500 turns, ingested sequentially so ordering and sequence assignment are preserved. Use it for backfill or to flush a retry buffer — not for the live path, where one-at-a-time keeps the latency measures honest.

Order is your responsibility. Turns are sequenced in arrival order, so post sequentially within one thread — the user's message before the reply to it. Concurrency across threads is fine and encouraged; concurrency within one thread can silently reorder a conversation.
Step 4

Subscribe to results

GET http://hwaichatanalysis.skoonline.org/api/v1/threads/conv_8f21/events
Accept: text/event-stream
Authorization: Bearer $TOKEN

Authorization is enforced before the stream opens; an unauthorized caller never receives a stream. The connection is bound to one thread, and the internal owner key is stripped from every frame.

id: evt_7e0a4b123b214d56a8fe8f0e72e39d03
event: turn.ingested
data: {"eventId":"evt_7e0a…","type":"turn.ingested","threadId":"thr_2c53…",
       "occurredAt":"2026-07-31T04:40:03.041Z",
       "data":{"turnId":"msg_1","sequence":0,"participantId":"user","speakerType":"human"}}
Event catalogue — sixteen types
EventFires whenTypical reaction
connectedStream opens; carries clientId and replayBufferSizeNote it; nothing to do
turn.ingestedA contribution was stored Optimistically mark the message analysed
analysis.core.completedLayer 1 finished for a turn Refresh per-turn measures
analysis.speech_act.startedClassification queued Show a pending indicator
analysis.speech_act.completedSpeech acts available Refresh acts and structural measures
analysis.speech_act.failedClassification failed Leave the prior value; do not show zero
semantic.startedLayer 2 began for a turnPending indicator
semantic.completedLayer 2 finishedRefresh LCC / ICCC
lcc.updatedLearner curves changedRefresh the LCC view
iccc.updatedCollaboration curves changedRefresh the ICCC view
iccc.social_impact.revised A provisional Social Impact became final Re-read; the earlier value was provisional
analysis.failedAn analysis job failedSurface degraded state
thread.metrics.updatedThread-level aggregates changed Refresh header stats
export.completedAn export finishedOffer the download
grant.updatedA research grant changedRefresh the sharing UI
heartbeatPeriodic keepaliveReset your idle timer
iccc.social_impact.revised deserves attention: Social Impact is genuinely revisable as later turns arrive. If you cache it, this event is your invalidation signal.
Reconnecting without losing events
Last-Event-ID: evt_7e0a4b123b214d56a8fe8f0e72e39d03

The hub keeps a replay buffer whose size is reported on connected. On reconnect, send the last id you processed — not the last you received — and events after it are replayed. Where you cannot set headers, ?lastEventId=evt_… does the same job.

Consume this in your backend, not the browser. The EventSource API cannot set an Authorization header, and identity from a query parameter is accepted only in mock mode — so a browser cannot subscribe directly in production. Relay to your frontend over your own transport, which is also the right place to decide which of your users may see which conversation.
Optional

Enabling Layer 2: LCC and ICCC

Layer 2 is never enabled implicitly. Requesting an engine requires its configuration block on the thread; requesting a mode without one is a 400.

PUT http://hwaichatanalysis.skoonline.org/api/v1/threads/conv_8f21/semantic-extension
{
  "enabled": true,
  "mode": "lcc",
  "interactionType": "tutorial_dyad",
  "lcc": {
    "referenceModelId": "rm_newton",
    "learnerParticipantIds": ["user"],
    "tutorParticipantIds": ["bot"],
    "classificationMethod": "hybrid"
  }
}

mode is none | lcc | iccc | lcc_and_iccc. Preconditions are enforced rather than assumed: a two-person chat does not qualify for LCC however long it runs, because LCC needs an instructional interactionType, a designated learner, a tutor role and a versioned reference model.

When preconditions are unmet the endpoint returns 200 with {"status":"not_applicable","reason":"…"} rather than a fabricated curve. Handle it as a valid answer, not an error.
Reference

Reading analysis back

SSE tells you something changed; these endpoints tell you what it now is. Base path /api/v1. The full table, including research access and exports, is in the API reference.

Analysis
MethodPathNotes
GET/threads/:threadId/analysis Thread-level Layer 1 with its versioned envelope
GET/threads/:threadId/analysis/turns/:turnId Per-turn measures, plus speech acts under the speech_acts scope
POST/threads/:threadId/reanalyze Recomputes Layer 1 and re-enqueues Layer 2. Safe to repeat
GET/threads/:threadId/jobs/:jobIdJob state
GET/threads/:threadId/lcc Learner curves; also /lcc/learners/:learnerId and /lcc/turns/:turnId
GET/threads/:threadId/iccc Collaboration curves; also /iccc/participants/:participantId
GET/api/v1/meta Algorithm and schema versions, active drivers, mock-mode flag. Unauthenticated
Owner retrieval and export
MethodPathNotes
GET/me/threadsThe owner's threads
GET/me/threads/:threadId/archive Everything: contributions, analyses, semantic artifacts, grants, access history
POST/me/threads/:threadId/exports Asynchronous; 202 with an export record
GET/me/exports/:exportId Status and, once complete, a short-lived signed URL
GET/me/threads/:threadId/exports/inline?format= Synchronous json, jsonl or csv

A signed URL is a delivery convenience issued after the ownership check. It is never treated as authorization.

In production

Idempotency, retries and ordering

Idempotency is keyed on thread + turnId. Retrying with the same turnId returns 200 with the stored record and does not duplicate — which is what makes at-least-once delivery from your side safe, including a buffer flush that already partly succeeded.

SituationDo
5xx / network failureRetry with backoff, same turnId
429Back off per the rate-limit headers, then retry
409 concurrent_modificationRetry — a manifest write raced
400 validation_failed Do not retry. Fix the payload; inspect error.details
401 / 403 Do not retry. Refresh the token or fix the delegation
Observatory unreachableBuffer locally, flush later via /turns/batch
In production

Errors

One envelope for everything, echoing the request id — also returned as the X-Request-Id header — so you can correlate with server logs.

{
  "error": {
    "code": "validation_failed",
    "message": "Request validation failed.",
    "details": [{ "path": "participantId", "message": "must be an opaque identifier…" }],
    "requestId": "req_357d09bdbe9a45ae9329d7b02be119c2"
  }
}
StatusCodeMeaning
400validation_failed Body or query failed schema validation; see details
400unsafe_identifierAn id violated the identifier pattern
400configuration_mismatch A semantic mode was requested without its configuration block
401unauthenticatedMissing or invalid token
401delegation_forbidden Delegation claim without application_client
403owner_required / role_required / scope_required Authenticated but not permitted
403cors_origin_deniedOrigin not in the allowlist
404not_foundUnknown thread, turn or export
409concurrent_modificationOptimistic-concurrency clash; retry
409incompatible_semantic_model The embedding space changed; re-analysis is needed
413—Body exceeded the size limit
429rate_limitedToo many requests
500internal_error Server fault; the message is deliberately generic
In production

Limits

LimitDefaultSet by
Request body1 MBREQUEST_BODY_LIMIT
Rate limit300 requests / minute RATE_LIMIT_MAX_REQUESTS, RATE_LIMIT_WINDOW_MINUTES
Turn text200 000 charactersschema
Turns per batch500schema
Participants per thread64schema
Identifier length128 charactersschema

All of these are configurable, so confirm the real values with this deployment's operator. GET /health and GET /ready report liveness and configuration readiness without disclosing secret values.

Before you ship

Reading the numbers honestly

null is not zero

It means "not measurable here", with a quality flag saying why. Coercing it to 0 silently corrupts every average you compute.

not_applicable is a real answer

Semantic engines refuse rather than fabricate when their preconditions are unmet. Handle it as a valid response, not an error.

Retries are safe

Ingestion is idempotent on thread + turnId. Reuse the same id and replay freely; do not retry a 400.

Research-integrity notice — returned with every analysis, and it constrains what your interface may claim:
These measures describe observable and computationally inferred interaction patterns. They do not, by themselves, establish learning, mastery, cognitive effort, engagement, trust, factual quality, intelligence, personality, or causal influence. Such interpretations require external measures and empirical validation.

This is the part of the contract most easily broken by a well-meaning UI. A measure describes how the interaction was distributed; a label like "engagement" or "mastery" on the same number is a claim the API has not made. The metric dictionary gives every measure alongside the interpretation it does not support — read the entry before you put the number on a screen.

Before you ship

Integration checklist

Everything published

The rest of the documentation

This page is the fast path. 14 further documents cover what it summarises — the ownership model in full, every metric with the interpretation it does not support, the two Layer 2 engines, and the machine-readable contract.

The contract

What the API accepts and returns. 4 documents →

Ownership, consent and access

Who the data belongs to, and who is allowed to read it. 5 documents →

What the numbers mean

Definitions, and the interpretations each measure does not support. 5 documents →