# Xapi

> Use when integrating xAPI (Experience API / Tin Can) in a web app or server with @studiolxd/xapi — sending/querying statements, managing State/Activity Profile/Agent Profile documents, parsing launch URLs, handling Result errors, or wiring the client across React, Vue, Angular, Svelte, vanilla JS, or LRS server helpers.

- Skill: `studiolxd/xapi` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add studiolxd/xapi`
- Raw SKILL.md: https://api.skillmd.com/api/skills/studiolxd/xapi/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: studiolxd (https://skillmd.com/u/studiolxd)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/studiolxd/xapi

---


# Using @studiolxd/xapi

`@studiolxd/xapi` is a headless xAPI 1.0.3/2.0 client. A framework-agnostic core
(`createXapiClient`) plus thin adapters, talking to **any** standards-compliant LRS.
Built-in **in-memory mock LRS** runs without a real LRS.

## Pick the entry point

- Vanilla / any framework: `import { createXapiClient, buildStatement, VERBS } from '@studiolxd/xapi'`
- React: `import { XapiProvider, useXapiClient, useXapiStatus } from '@studiolxd/xapi/react'`
- Vue: `import { useXapiClient } from '@studiolxd/xapi/vue'`
- Angular (>=17): `import { provideXapi, XAPI } from '@studiolxd/xapi/angular'`
- Svelte (>=4): `import { createXapiStore } from '@studiolxd/xapi/svelte'`
- Implementing an LRS (server side): `import { … } from '@studiolxd/xapi/server'`
- CDN `<script>`: global `window.Xapi`

## The golden rules

1. **Always check `.ok` before `.value`.** Every network method returns
   `Promise<Result<T, XapiError>>` — the API never throws. (Unlike SCORM, everything
   here is async: HTTP.)
2. **Build statements with `client.buildStatement()`** (or the standalone
   `buildStatement`): `verb`/`object` accept a plain IRI string, `id` (UUID) and
   `timestamp` (ISO 8601) are auto-generated, and the client's
   `defaults` (`actor`, `registration`, `context`) are merged in. An actor needs
   **exactly one** identifier: `mbox` | `mbox_sha1sum` | `openid` | `account`.
3. **Use the mock LRS for dev/tests:**
   `createXapiClient({ endpoint: 'https://mock.lrs/xapi', fetch: createMemoryLrs().fetch })`.
4. **Documents ≠ statements.** State/Activity Profile/Agent Profile are mutable
   key-value documents (session state, settings); statements are an immutable
   historical log. `getState`/`get*Profile` resolve `ok(null)` on 404 — missing is
   not an error.
5. **Voiding does not delete.** `voidStatement(targetId)` sends a new statement
   (`VERBS.voided` + `StatementRef`); the target disappears from normal queries but
   stays retrievable via `getVoidedStatement(id)`.
6. **Version is an option, not a fork:** `version: '1.0.3' | '2.0'` (default
   `'1.0.3'`); the client adapts payloads and headers per version.

## Canonical vanilla example

```ts
import { createXapiClient, VERBS } from '@studiolxd/xapi';

const client = createXapiClient({
  endpoint: 'https://lrs.example.com/xapi',
  auth: { username: 'key', password: 'secret' }, // or { token } or { header }
  defaults: { actor: { mbox: 'mailto:learner@example.com' } },
});

const sent = await client.sendStatement(
  client.buildStatement({
    verb: VERBS.completed,                    // or a raw IRI string
    object: 'https://example.com/course/1',   // IRI shorthand → Activity
    result: { score: { scaled: 0.9 }, success: true },
  }),
);
if (sent.ok) console.log('statement id:', sent.value);
else console.error(sent.error.kind, sent.error.status, sent.error.message);

// Session state survives page reloads via the State document API:
await client.setState('https://example.com/course/1', 'bookmark', { page: 4 });
const state = await client.getState('https://example.com/course/1', 'bookmark');
if (state.ok && state.value) console.log(state.value.content); // { page: 4 }
```

## Launch (content started by an LMS)

TinCan/Rustici-style launches pass `endpoint`, `auth`, `actor`, `registration` and
`activity_id` as query params. Don't parse them by hand:

```ts
import { createXapiClientFromLaunch } from '@studiolxd/xapi';
const launched = createXapiClientFromLaunch(); // reads window.location.href, SSR-safe
if (launched.ok) {
  const client = launched.value; // actor/registration already set as defaults
}
```

## Common gotchas (these cause real bugs)

- IRIs (`verb.id`, Activity `object.id`) must have a scheme and no spaces —
  `validateStatement` rejects them with `kind: 'validation'` before any network call.
- `mbox` must be `mailto:...`. `mbox_sha1sum` is a SHA-1 hex digest **without**
  `mailto:`. Never send both — exactly one identifier per actor.
- Resending the same statement `id` with **different** content → 409 conflict.
  Identical content is idempotent (no error, no duplicate).
- `getStatements()` paginates: follow `result.value.more` with
  `getMoreStatements(more)` until it is `null`.
- `concurrency: 'auto'` (default) manages document ETags and retries a 412 **once**;
  some strict 1.0.3 LRSs reject conditional headers — use `concurrency: 'off'` there.
- `./server` helpers use Web APIs only (`Request`/`Response`/`crypto.subtle`) — they
  run in Next.js Route Handlers/edge/Node >=18, but not with `node:http` objects.
- SSR (Next.js/Remix): `parseXapiLaunch()` without an explicit URL returns
  `err(kind: 'usage')` on the server instead of throwing — pass the URL explicitly
  or call it client-side only.
- `client.destroy()` when done (adapters do it for you on unmount/dispose).

## XapiError fields

`kind` (`'network'|'http'|'validation'|'timeout'|'version'|'usage'`), `operation`,
`endpoint`, `status` (HTTP code or null), `responseBody` (truncated LRS error body),
`issues` (validation only: `{ path, rule, message }[]`), `exception`.

