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 | ~77,000 |
lti | 1.0 only | Unmaintained | ~132,000 (legacy) |
plug_lti | 1.x | Unmaintained | Not on Hex |
lightbulb | 1.3 | Active (Gleam, not Elixir) | ~370 |
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 written after 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 three
callbacks for registration lookup, deployment lookup, and recording used
nonces. Two optional callbacks sign with your tool's key, so the
private key stays in your application or a key management service.
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 most have a spec_ref field.
A single failure is returned as an error struct, and several
failures found at once are grouped in their class struct.
case Ltix.handle_callback(params, state) do
{:ok, context} -> ...
{:error, %{class: :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.list_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 | Yes | No |
| Platform side | No (tool only) | Yes |
| Ecto provider | No (you implement the adapter) | Yes (lti_1p3_ecto_provider) |
| Test helpers | Yes (Ltix.Test) | In-memory provider |
Dependencies
| Ltix | lti_1p3 |
|---|---|
req | httpoison |
jose | joken |
splode | — |
zoi | — |
telemetry | — |
recase | — |
plug (optional) | — |
| — | timex |
| — | uuid |
jason (optional) | 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 (three required 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 three required callbacks your app implements- Error Handling - working with structured errors
- Advantage Services - OAuth and service calls
- Grade Service - posting scores and managing line items