The xAPI profile for learning events

Schools' learning record stores (LRSs) read xAPI.

7 min read · Updated Nov 5, 2026

Commands here say viable-harness. Until it is on npm, use npx github:VIABLE-Learning-Lab/viable in its place: it takes every command.

Schools' learning record stores (LRSs) read xAPI. toStatements(record, { homePage, activityBase, includeText }) in src/xapi.js, and viable-harness xapi, turn one learner's record (schemas/learning-events.schema.json: its events and turns) into xAPI 1.0.3 statements. The harness builds the statements and sends nothing: a deployer posts them to their LRS.

sh
viable-harness xapi record.json --home-page https://school.example.org [--activity-base <url>] [--include-text]
js
import { toStatements } from '@viable-lab/engine/harness/xapi.js'; // npm i github:VIABLE-Learning-Lab/viable
const statements = toStatements(record, { homePage: 'https://school.example.org' });

conformance/xapi.json holds the cases another implementation of this profile checks itself against. schemas/xapi-profile.jsonld is the same profile as an xAPI Profile (xAPI Profiles 1.0, JSON-LD): VIABLE's own verbs, activity types and extensions, each with its definition, written from VOCABULARY in src/xapi.js by npm run xapi:build. The VIABLE Developer Platform publishes it at https://developers.viablelab.org/xapi, the profile's IRI, and answers every IRI under it (each verb, activity type and extension, such as …/verbs/requested-hint) with the profile as JSON-LD. The record's schema is published there too, at its $id, https://developers.viablelab.org/schemas/learning-events.schema.json.

A statement

Property What it holds
id A UUID v5 under the namespace 85904702-c63a-51b6-80a6-6d17f20e12a7 (itself the UUID v5 of https://developers.viablelab.org/xapi in the URL namespace). The name is the JSON, keys sorted and no spaces, of [homePage, user, activity, session, { event }]; for a turn { turn }, and for an answer its agent graded { turn, assessment: <its index> }. Missing parts are null.
actor { objectType: "Agent", account: { homePage, name } }: an account at homePage named by the record's user, its pseudonymous id. Nothing else names the learner.
verb An ADL verb (http://adlnet.gov/expapi/verbs/…) where one fits, otherwise VIABLE's own under https://developers.viablelab.org/xapi/verbs/, with its word as display["en-US"].
object An activity under activityBase (default homePage), with its type.
result success, completion, score, duration and response where the event has them, and the event's data as extensions.
context The record's activity as contextActivities.parent, and as extensions the session, an event's seq, and a turn's versions, VETTING outcome and place in its conversation.
timestamp When the event happened (at, on the learner's clock) or the message was sent (started_at), in ISO 8601 UTC. Without one the statement has none, and the LRS stamps it when it stores it.

Activities

Activity IRIs are built from the ids the record carries, each part percent-encoded. With act = <activityBase>/<activity> (or activityBase alone when the record names no activity):

Object IRI Type
The activity act https://developers.viablelab.org/xapi/activity-types/activity
A panel act/panels/<panel> (app when the event names none) …/activity-types/panel
A plan or draft act/panels/<panel>/<field> …/activity-types/writing
A rubric act/panels/<panel>/rubric …/activity-types/rubric
A rubric criterion act/panels/<panel>/rubric/criteria/<criterion> …/activity-types/rubric-criterion
A page of a reading act/panels/<panel>/pages/<page> …/activity-types/page
An embedded page's object act/panels/<panel>/objects/<object> …/activity-types/embedded-object
A question act/panels/<panel>/items/<item> http://adlnet.gov/expapi/activities/question
The AI in the activity act/ai …/activity-types/ai
An item the agent graded act/items/<item> http://adlnet.gov/expapi/activities/question
A skill the agent graded, without an item act/skills/<skill> http://adlnet.gov/expapi/activities/objective

… is https://developers.viablelab.org/xapi. An object whose id is missing (a draft event without a field, a page event without a page) is the one it belongs to: the panel, or for a criterion the rubric.

Verbs

Event Verb Object Result
panel.view experienced (ADL) the panel
writing.start attempted (ADL) the plan or draft
writing.progress progressed (ADL) the plan or draft
writing.snapshot saved the plan or draft
writing.rubric experienced (ADL) the rubric
writing.rubric_check checked, or unchecked when checked is false the criterion
writing.submit completed (ADL) the panel completion: true
embed.event the page's own verb: ADL's when ADL has it (attempted, answered, completed, passed, failed, progressed, interacted, ...), otherwise VIABLE's; interacted (ADL) without one the page's object, or the panel success, completion, score (scaled with max_score), duration from duration_ms, response
reading.page experienced (ADL) the page
reading.highlight highlighted, or unhighlighted when removed the page
reading.note annotated the page
reading.search searched the panel
questions.view experienced (ADL) the question (item), or the panel
questions.hint requested-hint the question, or the panel
questions.answer answered (ADL) the question, or the panel success from correct, score, response
app.event interacted (ADL) the panel
a turn asked (ADL) the AI
each answer in a turn's assessment answered (ADL) the item, or the skill success from correct

Verbs without "(ADL)" are VIABLE's, under https://developers.viablelab.org/xapi/verbs/ (…/verbs/requested-hint).

Skipped, with no statement:

  • page.layout: how the page is arranged (the AI panel's width, collapsed or open), not something the learner did in the activity.
  • embed.state: an embedded page's saved state, for picking up where the learner left off. xAPI keeps that in its State resource, not in statements.

An event of a type the schema doesn't list makes no statement either. Two identical events make one statement, since an LRS refuses a batch that repeats an id.

A score is sent as { raw, min: 0, max, scaled } when max_score is above 0 and the score is between 0 and it, and as { raw } alone otherwise: xAPI requires raw between min and max.

Extensions

Every extension is named after the field it comes from, under https://developers.viablelab.org/xapi/extensions/ (…/extensions/visible_ms).

  • Result: an event's data, except what the statement carries elsewhere (verb, object, field, correct, success, completion, score, max_score, duration_ms, response): its numbers and true/false, strings that name something (set, skill, item_type, color, content_type, asset_id, source), and nested objects the same way. For a turn, first_output_ms, signals and after; for an answer its agent graded, skill.
  • Context: the record's session, and from the event or turn, when it has them: seq, conversation_id, turn_index, agent_version, activity_version, vetting and vetting_attempts. vetting is the review's outcome: passed, unverified (it ran out of tries) or attention (a safety concern); VIABLE flags unverified and attention turns for the teacher. These turn fields are read by the names the research export's turns.csv gives them; the learning-events schema allows them on a turn without listing them yet.

What is never sent

  • A name, an email address or any identifier of the learner but the record's user. A user that looks like an email address is refused, and so is a record without one.
  • Without includeText (the default): what the learner wrote (a snapshot's text, an answer's response, an embedded page's response), a question's prompt, a turn's message and the AI's reply, and any other string in data, such as an app's own fields: the harness can't tell a label from something a learner typed.
  • Lists inside data.
  • The skipped event types above.

What includeText adds

A deployer opts in, for an LRS that may hold learners' writing. With includeText: true (--include-text), an answer's or embedded page's response becomes the result's response, and every other string, a snapshot's text, a question's prompt, a turn's message and reply and an app's own strings, becomes a result extension named after its field. Ids don't change.

Sending statements to an LRS

The deployer posts the statements; the harness makes no network call.

sh
viable-harness xapi record.json --home-page https://school.example.org > statements.json
curl -X POST "$LRS_ENDPOINT/statements" \
  -H 'X-Experience-API-Version: 1.0.3' -H 'Content-Type: application/json' \
  -u "$LRS_KEY:$LRS_SECRET" --data @statements.json
  • homePage names the system whose ids the learners' user values are: keep it the same across exports (https://school.example.org and https://school.example.org/ are different accounts).
  • Exporting a record again gives the same ids, and an LRS doesn't store a statement it already has, so re-sending doesn't duplicate. A statement whose content changed under the same id (after switching includeText, or changing activityBase) is refused, often with 409 Conflict: xAPI statements can't be changed, only voided.
  • LRSs limit how many statements one request may carry; split a large export into batches.
  • What the LRS keeps, for how long and who can read it is the deployer's to agree with the school.

This page comes from the open repository: harness/docs/xapi.md. To change it, change it there.

This page is also plain Markdown at /docs/harness/xapi.md. Point your AI at /llms.txt to find every page.