Realtime Channels
Live event streams scoped to a course or user. Extension apps subscribe to a channel, receive any events that other clients publish on it, and can publish their own.
Requires @teachfloor/extension-kit ≥ 1.22.0. Earlier versions of the kit don't ship the realtime namespace. Bump your app's dependency before adding the realtime permission to your manifest.
What you can build
- Live cohort presence — "3 learners are also on this course right now".
- Multiplayer review — flashcards / quizzes / polls with synchronized state.
- Live admin dashboards — "new submission received", "X just completed the module".
Channel model
A channel is identified by a scope and a resource id. Two scopes are available:
| Scope | Resource id | Who can subscribe |
|---|---|---|
course | The id of a course in the user's organization. | Any member of that course's organization who has the app installed with the realtime permission. |
user | The current user's id. | Only the user themselves. |
The resource id is the same id you receive from useExtensionContext() — for example environment.context.course.id on a course viewport. You never construct the channel string yourself; the SDK does it for you.
Channels are private: Teachfloor authenticates every subscription server-side and enforces the rule in the table above before accepting it.
Permissions
Add the realtime permission to your manifest:
Code
The realtime permission grants:
- Subscribing to
course-scoped channels for any course in an organization the user is a member of. - Subscribing to your own
user-scoped channel (scope: 'user',id: <auth user>). - Publishing to channels you're subscribed to.
It does not grant cross-app, cross-org, or other-user channel access.
Resource ids are gated by their own read permissions. Subscribing to a course-scoped channel needs courses:read in addition to realtime — without it the host strips course from the viewport payload, so environment.context.course.id is undefined and there's no id to subscribe with. The same applies for modules:read and elements:read if you ever derive an id from those contexts.
SDK API
Code
Event payload shape
What your on handler receives:
Code
fromUserId is the publisher's id — the same value you get from useExtensionContext().userContext.id. You can compare it against your own user id to filter your own activity, or use it in a navigation helper like goToPath('/${slug}/users/${fromUserId}') to deep-link to the peer's profile.
Notes
- Publishers do not receive their own events back. Update your own UI optimistically when you call
.publish(). - Subscription and publish failures (wrong scope, missing permission, rate limit hit, network blip) are delivered to the channel's
onError(…)handler — register one to log or surface them. Without it, failures are dropped. - There's no message history. If a learner joins after an event was published, they won't see it. Cache state in
appdata/userdataif you need persistence.
Limits
| Limit | Value |
|---|---|
| Event name | ^[a-z][a-z0-9_]*$, max 64 chars |
| Payload size | 4 KB per published message |
| Per user, per app | 60 messages / minute |
| Per app (org-wide) | 600 messages / minute |
| Concurrent subscriptions per app instance | unlimited (but bounded by the host's single WebSocket) |
Rate limits return 429 with a retry_after_seconds field; the SDK surfaces them through channel.onError(…) with code: 'rate_limited'.
Example: live "currently here" counter
Realtime channels don't keep a presence roster for you — building one is a pattern of three pieces: announce yourself, refresh the announcement on a heartbeat, and locally drop peers you haven't heard from in a while. The example below shows all three.
Code
The numbers come from a few design choices worth thinking through for your own app:
- Heartbeat interval trades freshness against publish budget. At 30 seconds, each learner uses 2 of their 60-events-per-minute budget — leaves plenty of headroom for other event types.
- Stale window is set to two missed heartbeats so a transient network blip doesn't drop someone for a beat.
- Prune tick controls how smoothly peers fade out in the UI; smaller values feel snappier but cost a tiny bit more re-rendering.
Security model
Permission and scope checks run server-side on both the subscribe and publish paths. Even if a client bypassed the SDK and called the underlying API directly, it would still hit the same checks:
- The app must be installed in the resource's organization and declare the
realtimepermission. - The user must have access to the underlying resource (member of the course's org for
coursechannels; only their own user id foruserchannels). - Publish payloads are validated against the size and rate limits above.
There is no way for an app to read a channel it doesn't subscribe to, or to publish on behalf of another user.
Next Steps
→ Continue to Webhooks
Additional Resources
- Webhooks - Signed HTTP deliveries to your app's backend. Complementary to realtime — reaches your server even when no learner is online.
- Permissions - The full
realtimepermission listing alongside the rest of the platform's permissions. - Data Storage - Pair realtime with
appdata/userdatafor state that survives reloads.