Comparing Elixir LTI Libraries

Copy Markdown View Source

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

LibraryLTI versionStatusHex downloads
Ltix1.3ActiveNew
lti_1p31.3Active~77,000
lti1.0 onlyUnmaintained~132,000 (legacy)
plug_lti1.xUnmaintainedNot on Hex
lightbulb1.3Active (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)}")
end

lti_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}} -> ...
end

Advantage 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

Ltixlti_1p3
OIDC launchYesYes
AGSYesYes
NRPSYesYes
Deep LinkingYesNo
Platform sideNo (tool only)Yes
Ecto providerNo (you implement the adapter)Yes (lti_1p3_ecto_provider)
Test helpersYes (Ltix.Test)In-memory provider

Dependencies

Ltixlti_1p3
reqhttpoison
josejoken
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