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.
| URL | What 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. |
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.
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.
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.
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>
| Requirement | Where it lives |
|---|---|
application_client | The roles / role claim |
| The owner's subject | act.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.
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
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.
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}
}'
Never per streamed token. Capture the timings during streaming and attach them to the single call when the message finishes.
Forward the real numbers. Locally estimated counts are flagged and weaken the production ratios that depend on them.
Enqueue the call; don't await it while drawing. If we are unreachable your chat must keep working.
| Field | Type | Notes |
|---|---|---|
participantId * | identifier | Must already exist on the thread |
speakerType * | enum | human assistant agent avatar
system tool |
turnId | identifier | Supply this. Your message id — it is what makes the call idempotent |
text | string | ≤ 200 000 characters |
parentTurnId | identifier | The message being replied to |
sequence | int ≥ 0 | Usually omit; assigned server-side |
explicitTargetParticipantIds | identifier[] | ≤ 64. Who this turn addresses |
submittedAt | ISO 8601 | When the turn was issued |
firstTokenAt / lastTokenAt | ISO 8601 | Streaming boundaries |
model / provider | string | ≤ 120 characters |
finishReason | string | ≤ 64 characters |
usage | object | newInputTokens totalApiInputTokens outputTokens
visibleOutputTokens cachedInputTokens
reasoningTokens |
metadata | object | streaming toolCalls locale
pageFocused clientRequestId; extra keys allowed |
| Field | If 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 |
parentTurnId | Reply structure is unavailable to target resolution |
model / provider | Per-model comparisons are unavailable |
{
"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.
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.
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 | Fires when | Typical reaction |
|---|---|---|
connected | Stream opens; carries clientId and
replayBufferSize | Note it; nothing to do |
turn.ingested | A contribution was stored | Optimistically mark the message analysed |
analysis.core.completed | Layer 1 finished for a turn | Refresh per-turn measures |
analysis.speech_act.started | Classification queued | Show a pending indicator |
analysis.speech_act.completed | Speech acts available | Refresh acts and structural measures |
analysis.speech_act.failed | Classification failed | Leave the prior value; do not show zero |
semantic.started | Layer 2 began for a turn | Pending indicator |
semantic.completed | Layer 2 finished | Refresh LCC / ICCC |
lcc.updated | Learner curves changed | Refresh the LCC view |
iccc.updated | Collaboration curves changed | Refresh the ICCC view |
iccc.social_impact.revised |
A provisional Social Impact became final | Re-read; the earlier value was provisional |
analysis.failed | An analysis job failed | Surface degraded state |
thread.metrics.updated | Thread-level aggregates changed | Refresh header stats |
export.completed | An export finished | Offer the download |
grant.updated | A research grant changed | Refresh the sharing UI |
heartbeat | Periodic keepalive | Reset 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.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.
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.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.
200 with
{"status":"not_applicable","reason":"…"} rather than a fabricated curve.
Handle it as a valid answer, not an error.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.
| Method | Path | Notes |
|---|---|---|
| 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/:jobId | Job 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 |
| Method | Path | Notes |
|---|---|---|
| GET | /me/threads | The 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.
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.
| Situation | Do |
|---|---|
5xx / network failure | Retry with backoff, same turnId |
429 | Back off per the rate-limit headers, then retry |
409 concurrent_modification | Retry — 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 unreachable | Buffer locally, flush later via /turns/batch |
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"
}
}
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_failed |
Body or query failed schema validation; see details |
| 400 | unsafe_identifier | An id violated the identifier pattern |
| 400 | configuration_mismatch |
A semantic mode was requested without its configuration block |
| 401 | unauthenticated | Missing or invalid token |
| 401 | delegation_forbidden |
Delegation claim without application_client |
| 403 | owner_required / role_required / scope_required |
Authenticated but not permitted |
| 403 | cors_origin_denied | Origin not in the allowlist |
| 404 | not_found | Unknown thread, turn or export |
| 409 | concurrent_modification | Optimistic-concurrency clash; retry |
| 409 | incompatible_semantic_model |
The embedding space changed; re-analysis is needed |
| 413 | — | Body exceeded the size limit |
| 429 | rate_limited | Too many requests |
| 500 | internal_error |
Server fault; the message is deliberately generic |
| Limit | Default | Set by |
|---|---|---|
| Request body | 1 MB | REQUEST_BODY_LIMIT |
| Rate limit | 300 requests / minute | RATE_LIMIT_MAX_REQUESTS, RATE_LIMIT_WINDOW_MINUTES |
| Turn text | 200 000 characters | schema |
| Turns per batch | 500 | schema |
| Participants per thread | 64 | schema |
| Identifier length | 128 characters | schema |
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.
null is not zeroIt 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 answerSemantic engines refuse rather than fabricate when their preconditions are unmet. Handle it as a valid response, not an error.
Ingestion is idempotent on thread + turnId. Reuse the same id and replay
freely; do not retry a 400.
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.
application_client and act.sub / on_behalf_ofthreadId and turnId are stable ids from your own system[A-Za-z0-9][A-Za-z0-9_-]{0,127} and contain no personal informationsubmittedAt, firstTokenAt and lastTokenAt captured during streamingusage forwarded, not re-derivedturnId; a 4xx is not retriedLast-Event-ID resumeiccc.social_impact.revised invalidates any cached Social Impactnull rendered as "not measurable", never 0not_applicable from LCC / ICCC handled as a valid answerThis 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.
What the API accepts and returns. 4 documents →
Who the data belongs to, and who is allowed to read it. 5 documents →
Definitions, and the interpretations each measure does not support. 5 documents →