> ## Documentation Index
> Fetch the complete documentation index at: https://docs.timeback.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Stateful activities

> Track activities that students complete across multiple sessions

Stateful activities span multiple sessions. The client SDK tracks time per session via heartbeats, and the server records completion when the activity is truly done.

<Tip>
  For info on shared concepts, see the [Activity Tracking
  overview](/beta/build-on-timeback/sdk/activity-tracking/intro).
</Tip>

## Architecture

Stateful activities split responsibility between frontend and backend:

| Responsibility    | Owner               | Mechanism                                                                  |
| ----------------- | ------------------- | -------------------------------------------------------------------------- |
| **Time tracking** | Frontend or Backend | [Heartbeats](#starting-a-session) or [`time: false`](#server-managed-time) |
| **State**         | Backend             | Your app's database                                                        |
| **Completion**    | Backend             | `timeback.activity.record()`                                               |

A `runId` ties all sessions together. The SDK generates one automatically when you call `activity.start()`, but you can also provide your own. Any unique string works. If your app already has a unique identifier for the activity attempt (e.g. an assignment row ID), pass it as `runId` directly instead of storing a separate value. On resume, pass the same `runId` so every heartbeat and the final completion event share the same identifier.

```mermaid theme={null}
graph TD
    DB[("Your Database")]

    subgraph S1 [Session 1 - Monday]
        A["activity.start()"] -->|generates runId| B["Heartbeats"]
        B --> C["activity.end()"]
    end

    C -->|save runId| DB
    DB -->|load runId| E

    subgraph S2 [Session 2 - Wednesday]
        E["activity.start with runId"] --> F["Heartbeats"]
        F --> G["activity.end()"]
    end

    DB -->|load runId| H

    subgraph S3 [Server]
        H["timeback.activity.record with runId"]
    end

    B -- TimeSpentEvent --> I["Caliper API"]
    C -- TimeSpentEvent --> I
    F -- TimeSpentEvent --> I
    G -- TimeSpentEvent --> I
    H -- ActivityCompletedEvent --> I

    style A fill:#3b82f6,stroke:#2563eb,color:#fff
    style E fill:#3b82f6,stroke:#2563eb,color:#fff
    style H fill:#10b981,stroke:#059669,color:#fff
    style I fill:#8b5cf6,stroke:#7c3aed,color:#fff
    style DB fill:#f59e0b,stroke:#d97706,color:#fff
```

## Frontend

The frontend tracks time per session and orchestrates the student's workflow (starting, resuming, and submitting answers). But completion must come from the server: across multiple sessions, it's the only part of the system that has the full picture of the student's accumulated progress.

### Starting a session

```typescript theme={null}
const activity = timeback.activity.start({
	id: 'lesson-1',
	name: 'Fractions',
	course: { subject: 'Math', grade: 3 },
})

// Your API — persist the runId so you can pass it back on resume
await saveProgress({ lessonId: 'lesson-1', runId: activity.runId })
```

<Info>
  See the [reference](/beta/build-on-timeback/sdk/activity-tracking/reference#activity-start-params) for full parameter documentation including time options and callbacks.
</Info>

### Resuming a session

```typescript theme={null}
const progress = await loadProgress({ lessonId: 'lesson-1' })

const activity = timeback.activity.start({
	id: 'lesson-1',
	name: 'Fractions',
	course: { subject: 'Math', grade: 3 },
	runId: progress.runId, // reuse the saved runId
})
```

### Ending a session

This is the key difference from [single-session activities](/beta/build-on-timeback/sdk/activity-tracking/single-session): the student can leave at any point — close the tab, navigate away, or simply stop for the day — without losing their progress. Calling `activity.end()` flushes the accumulated time but does not record a completion. The activity stays open, ready to be resumed later with the same `runId`.

```typescript theme={null}
await activity.end() // Flushes time, no completion
```

<Note>
  The frontend **always** calls `activity.end()` without metrics. Completion is recorded by the
  [server](#server-recording-completion).
</Note>

### Framework examples

<Tabs>
  <Tab title="React">
    ```tsx theme={null}
    import { useEffect, useRef } from 'react'
    import { useTimeback } from '@timeback/sdk/react'

    function ResumableLesson({ lessonId, lessonName }) {
    	const timeback = useTimeback()
    	const activityRef = useRef(null)

    	useEffect(() => {
    		if (!timeback) return

    		async function startTracking() {
    			// Your API — load saved progress (if any)
    			const res = await fetch(`/api/progress/${lessonId}`)
    			const progress = await res.json()

    			activityRef.current = timeback.activity.start({
    				id: lessonId,
    				name: lessonName,
    				course: { subject: 'Math', grade: 3 },
    				...(progress?.runId && { runId: progress.runId }),
    			})

    			// Your API — persist runId on first start
    			if (!progress?.runId) {
    				await fetch(`/api/progress/${lessonId}`, {
    					method: 'POST',
    					body: JSON.stringify({ runId: activityRef.current.runId }),
    				})
    			}
    		}

    		startTracking()

    		return () => {
    			// Flush time on unmount — server handles completion
    			void activityRef.current?.end()
    		}
    	}, [timeback, lessonId, lessonName])

    	return <div>Lesson content...</div>
    }
    ```
  </Tab>

  <Tab title="Vue">
    ```vue theme={null}
    <script setup>
    import { onMounted, onUnmounted, ref } from 'vue'
    import { useTimeback } from '@timeback/sdk/vue'

    const props = defineProps(['lessonId', 'lessonName'])
    const timeback = useTimeback()
    const activity = ref(null)

    onMounted(async () => {
    	if (!timeback.value) return

    	// Your API — load saved progress (if any)
    	const res = await fetch(`/api/progress/${props.lessonId}`)
    	const progress = await res.json()

    	activity.value = timeback.value.activity.start({
    		id: props.lessonId,
    		name: props.lessonName,
    		course: { subject: 'Math', grade: 3 },
    		...(progress?.runId && { runId: progress.runId }),
    	})

    	// Your API — persist runId on first start
    	if (!progress?.runId) {
    		await fetch(`/api/progress/${props.lessonId}`, {
    			method: 'POST',
    			body: JSON.stringify({ runId: activity.value.runId }),
    		})
    	}
    })

    onUnmounted(() => {
    	// Flush time on unmount — server handles completion
    	void activity.value?.end()
    })
    </script>
    ```
  </Tab>

  <Tab title="Svelte">
    ```svelte theme={null}
    <script>
    	import { onMount, onDestroy } from 'svelte'
    	import { timeback } from '@timeback/sdk/svelte'

    	export let lessonId
    	export let lessonName

    	let activity

    	onMount(async () => {
    		if (!$timeback) return

    		// Your API — load saved progress (if any)
    		const res = await fetch(`/api/progress/${lessonId}`)
    		const progress = await res.json()

    		activity = $timeback.activity.start({
    			id: lessonId,
    			name: lessonName,
    			course: { subject: 'Math', grade: 3 },
    			...(progress?.runId && { runId: progress.runId }),
    		})

    		// Your API — persist runId on first start
    		if (!progress?.runId) {
    			await fetch(`/api/progress/${lessonId}`, {
    				method: 'POST',
    				body: JSON.stringify({ runId: activity.runId }),
    			})
    		}
    	})

    	onDestroy(() => {
    		// Flush time on unmount — server handles completion
    		void activity?.end()
    	})
    </script>
    ```
  </Tab>

  <Tab title="Solid">
    ```tsx theme={null}
    import { onMount, onCleanup } from 'solid-js'
    import { useTimeback } from '@timeback/sdk/solid'

    function ResumableLesson(props) {
    	const timeback = useTimeback()
    	let activity

    	onMount(async () => {
    		if (!timeback) return

    		// Your API — load saved progress (if any)
    		const res = await fetch(`/api/progress/${props.lessonId}`)
    		const progress = await res.json()

    		activity = timeback.activity.start({
    			id: props.lessonId,
    			name: props.lessonName,
    			course: { subject: 'Math', grade: 3 },
    			...(progress?.runId && { runId: progress.runId }),
    		})

    		// Your API — persist runId on first start
    		if (!progress?.runId) {
    			await fetch(`/api/progress/${props.lessonId}`, {
    				method: 'POST',
    				body: JSON.stringify({ runId: activity.runId }),
    			})
    		}
    	})

    	onCleanup(() => {
    		// Flush time on unmount — server handles completion
    		void activity?.end()
    	})

    	return <div>Lesson content...</div>
    }
    ```
  </Tab>
</Tabs>

### Server-managed time

If your server already tracks time (e.g. from request logs or its own session model), you can disable heartbeats entirely by passing `time: false`. The client still provides the `activity.end()` ergonomics, but no `TimeSpentEvent`s are sent.

```typescript theme={null}
const activity = timeback.activity.start({
	id: 'lesson-1',
	name: 'Fractions',
	course: { subject: 'Math', grade: 3 },
	runId: progress.runId,
	time: false,
})

// No heartbeats during the session — end() closes the activity without sending time data
await activity.end()
```

<Warning>
  When using `time: false`, the server must report time via the `time` parameter in
  `timeback.activity.record()`. Do **not** use both heartbeats and server-side time — this will
  double-count. See the [server reference](/beta/build-on-timeback/sdk/activity-tracking/reference#server) for details.
</Warning>

## Backend

When a student completes an activity, as determined by your app's logic, the backend records the result using `timeback.activity.record()`. This sends an [`ActivityCompletedEvent`](/beta/build-on-timeback/reference/events#activitycompletedevent) to the Caliper API and runs the full pipeline including gradebook and [XP](/beta/about-timeback/concepts/xp-system).

Pass the same `runId` from the frontend to correlate heartbeats with the completion event.

<Info>
  See the [reference](/beta/build-on-timeback/sdk/activity-tracking/reference#timeback-activity-record-params) for full parameter documentation.
</Info>

<CodeGroup>
  ```typescript TypeScript theme={null}
  await timeback.activity.record({
  	user: { email: 'student@example.com' },
  	activity: {
  		id: 'lesson-1',
  		name: 'Fractions',
  		course: { code: 'MATH-3' },
  	},
  	metrics: {
  		totalQuestions: 20,
  		correctQuestions: 16,
  		xpEarned: 150,
  		masteredUnits: 2,
  		pctComplete: 100,
  	},
  	runId: savedProgress.runId,
  })
  ```

  ```python Python theme={null}
  await timeback.activity.record({
      "user": {"email": "student@example.com"},
      "activity": {
          "id": "lesson-1",
          "name": "Fractions",
          "course": {"code": "MATH-3"},
      },
      "metrics": {
          "total_questions": 20,
          "correct_questions": 16,
          "xp_earned": 150,
          "mastered_units": 2,
          "pct_complete": 100,
      },
      "run_id": saved_progress.run_id,
  })
  ```
</CodeGroup>

<Tip>
  Only include `time` when the frontend uses [`time: false`](#server-managed-time). If heartbeats
  are active (the default), the platform already has the time data — passing `time` here would
  double-count. See the full [server reference](/beta/build-on-timeback/sdk/activity-tracking/reference#server)
  for all parameters.
</Tip>

## Best practices

<AccordionGroup>
  <Accordion title="Persist runId immediately after activity.start()">
    The `runId` is the correlation key between frontend heartbeats and backend completion. Save
    it to your database as soon as the activity starts.
  </Accordion>

  <Accordion title="Use a fresh runId for new attempts after completion">
    When a student starts a new attempt of a previously completed activity, do not reuse the old
    `runId`. Omit it from `activity.start()` to generate a fresh one — otherwise new heartbeats
    would be incorrectly correlated with the old completion event.
  </Accordion>

  <Accordion title="Only call activity.end() without args on the frontend">
    Completion should come from the backend via `timeback.activity.record()`. The frontend
    should always use `activity.end()` (no metrics) to flush time data only.
  </Accordion>
</AccordionGroup>

## Next steps

<CardGroup cols={2}>
  <Card title="Single-session activities" icon="bolt" href="/beta/build-on-timeback/sdk/activity-tracking/single-session">
    Simpler model for one-sitting activities
  </Card>

  <Card title="Reference" icon="code" href="/beta/build-on-timeback/sdk/activity-tracking/reference">
    Full parameter and method reference
  </Card>

  <Card title="Identity" icon="user" href="/beta/build-on-timeback/sdk/identity">
    Authentication setup for resolving users
  </Card>
</CardGroup>
