Elixir's LTI ecosystem is small. If you need LTI 1.3 support, there are
two real options: Ltix and lti_1p3. This guide compares their design
philosophies so you can choose the right fit for your project.
Landscape
| Library | LTI version | Status | Hex downloads |
|---|---|---|---|
| Ltix | 1.3 | Active | New |
lti_1p3 | 1.3 | Active | ~62,000 |
lti | 1.0 only | Unmaintained | ~132,000 (legacy) |
plug_lti | 1.x | Unmaintained | Not on Hex |
lightbulb | 1.3 | Active (Gleam, not Elixir) | ~200 |
lti_1p3 comes from the
Simon Initiative at Carnegie Mellon University. It was generously
extracted from OLI Torus,
a large Phoenix/LiveView learning platform, and has been in production
since 2021. The same team maintains lightbulb, a Gleam LTI library
targeting the BEAM.
Ltix would not exist without lti_1p3. It was born from extensive
experience building tools on top of lti_1p3 and reflects lessons
learned about what a tool-focused LTI library could look like when
designed from scratch.
Different starting points
The libraries come from different contexts, which shaped their designs:
lti_1p3 was extracted from a production LMS. It supports both the
platform and tool sides of LTI, includes database-backed storage with
Ecto schemas, and reflects the needs of a large application that manages
its own registrations, JWKs, and platform instances.
Ltix was designed as a standalone library for tool developers. It focuses on the tool side only, keeps storage abstract, and tries to minimize the surface area a host app needs to implement.
Neither approach is wrong — they serve different audiences.
API comparison
Launch flow
Both libraries handle the three-step OIDC launch. The main difference is how claims are returned.
Ltix parses claims into typed structs during validation:
{:ok, %{redirect_uri: url, state: state}} =
Ltix.handle_login(params, launch_url)
{:ok, context} = Ltix.handle_callback(params, state)
context.claims.roles #=> [%Role{type: :context, name: :instructor}]
context.claims.context.title #=> "Intro to Elixir"
context.claims.target_link_uri #=> "https://mytool.example.com/launch"lti_1p3 returns the validated JWT as a map, preserving the
original claim URIs:
{:ok, state, redirect_url} =
Lti_1p3.Tool.OidcLogin.oidc_login_redirect_url(params)
{:ok, claims} =
Lti_1p3.Tool.LaunchValidation.validate(params, session_state)
claims["https://purl.imsglobal.org/spec/lti/claim/roles"]
#=> ["http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor"]
claims["https://purl.imsglobal.org/spec/lti/claim/context"]["title"]
#=> "Intro to Elixir"Storage
Ltix defines a single Ltix.StorageAdapter behaviour with four
callbacks — registration lookup, deployment lookup, nonce storage,
and nonce validation. JWK management and other concerns are handled
internally.
lti_1p3 provides three behaviours (DataProvider,
ToolDataProvider, PlatformDataProvider) with a broader callback
surface. This gives the host app more control over JWK lifecycle,
login hints, and platform instances. The companion package
lti_1p3_ecto_provider provides a ready-made Ecto implementation.
Error handling
Ltix uses Splode to define a
structured exception hierarchy. Errors are grouped into three classes
(:invalid, :security, :unknown) and carry spec_ref fields:
case Ltix.handle_callback(params, state) do
{:ok, context} -> ...
{:error, %Ltix.Errors.Security{} = error} ->
Logger.warning("Security: #{Exception.message(error)}")
endlti_1p3 returns error maps with :reason and :msg keys:
case Lti_1p3.Tool.LaunchValidation.validate(params, state) do
{:ok, claims} -> ...
{:error, %{reason: :invalid_registration, msg: msg}} -> ...
endAdvantage services
Ltix wraps service authentication and queries behind an authenticate-then-call pattern:
{:ok, client} = Ltix.MembershipsService.authenticate(context)
{:ok, roster} = Ltix.MembershipsService.get_members(client)lti_1p3 provides separate modules for token acquisition and
service calls, giving more visibility into each step:
{:ok, token} = Lti_1p3.Tool.Services.AccessToken.fetch_access_token(
registration, scopes, host
)
{:ok, members} = Lti_1p3.Tool.Services.NRPS.fetch_memberships(url, token)Feature comparison
| Ltix | lti_1p3 | |
|---|---|---|
| OIDC launch | Yes | Yes |
| AGS | Yes | Yes |
| NRPS | Yes | Yes |
| Deep Linking | Claim parsing | Claim parsing |
| Platform side | No (tool only) | Yes |
| Ecto provider | No (bring your own adapter) | Yes (lti_1p3_ecto_provider) |
| Test helpers | Yes (Ltix.Test) | In-memory provider |
Dependencies
| Ltix | lti_1p3 |
|---|---|
req | httpoison |
jose | joken |
splode | — |
zoi | — |
plug (optional) | — |
cachex (optional) | — |
| — | timex |
| — | uuid |
| — | jason |
Choosing between them
Ltix is a good fit when you are building a tool and want typed claims, structured errors, and a small integration surface (four storage callbacks). It focuses on ergonomics and correctness for the tool side of LTI.
lti_1p3 is a good fit when you need platform-side support, want
a ready-made Ecto storage layer, or prefer a library with several years
of production history. Its broader callback surface gives more control
over JWK lifecycle and platform management.
Next steps
- What is Ltix? — overview of Ltix's design
- Getting Started — integrate Ltix into a Phoenix app
Ltix.StorageAdapter— the four callbacks your app implements- Error Handling — working with structured errors
- Advantage Services — OAuth and service calls
- Grade Service — posting scores and managing line items