A basic self-hosted web server to ease the development and sharing of TRMNL plugins.
Liquid templates are rendered leveraging the TRMNL Design System. They may be generated as HTML (faster, and a good approximation of the final result) or as PNG images (slower, but more accurate).
The server watches the filesystem for changes to the Liquid templates, seamlessly updating the preview without the need to refresh.
trmnlp requires Ruby 4.0 or newer. Check with ruby -v before installing.
gem install trmnl_preview # install
trmnlp init my_plugin # scaffold a project
cd my_plugin
trmnlp serve # preview at http://localhost:4567No Ruby on hand? Run it through Docker instead — see Installing via Docker.
This is the structure of a plugin project:
.
├── .github
│ └── workflows
│ └── trmnl.yml
├── .gitignore
├── .trmnlp.yml
├── bin
│ └── trmnlp
└── src
├── full.liquid
├── half_horizontal.liquid
├── half_vertical.liquid
├── quadrant.liquid
├── shared.liquid
└── settings.yml
| File | Purpose |
|---|---|
.github/workflows/trmnl.yml |
GitHub Actions workflow — lints every PR, deploys to TRMNL on main |
.gitignore |
Keeps trmnlp build output out of version control |
.trmnlp.yml |
Local dev-server config — not uploaded to TRMNL |
src/full.liquid |
Markup for the full screen |
src/half_horizontal.liquid |
Top or bottom half of a stacked mashup |
src/half_vertical.liquid |
Left or right half of a side-by-side mashup |
src/quadrant.liquid |
One quarter of a 2x2 mashup |
src/shared.liquid |
Reusable markup included by the other templates |
src/settings.yml |
Plugin configuration — uploaded to TRMNL |
You can start building a plugin locally, then push it to the TRMNL server for display on your device.
trmnlp init [my_plugin] # generate
cd [my_plugin]
trmnlp serve # develop locally
trmnlp login # authenticate
trmnlp push # uploadIf you have built a plugin with the web-based editor, you can clone it, work on it locally, and push changes back to the server.
trmnlp login # authenticate
trmnlp clone [my_plugin] [id] # first download
cd [my_plugin]
trmnlp serve # develop locally
trmnlp push # upload| Command | Description |
|---|---|
trmnlp init NAME |
Start a new plugin project |
trmnlp serve |
Start a local dev server |
trmnlp build |
Generate static HTML files, or PNGs with --png |
trmnlp lint |
Check plugin code against TRMNL best practices |
trmnlp test |
Run the plugin's tests in tests/ |
trmnlp login |
Authenticate with TRMNL server |
trmnlp list |
List private plugins from TRMNL server |
trmnlp clone NAME ID |
Copy a plugin project from TRMNL server |
trmnlp pull |
Download latest plugin settings from TRMNL server |
trmnlp push |
Upload latest plugin settings to TRMNL server |
trmnlp version |
Show version |
trmnlp lint exits non-zero when it finds issues, so you can gate CI on it. Run trmnlp help for all flags.
Every command also checks RubyGems for a newer stable trmnl_preview release
and, when one is available, suggests gem update trmnl_preview, or
bundle update trmnl_preview when run through Bundler. An exact Gemfile pin must
be changed before Bundler can update it. Notices go to stderr, keeping lint JSON
output and exit status unchanged. The answer is cached for a day, so most runs
make no request; trmnlp version always asks RubyGems. --quiet or
TRMNLP_NO_UPDATE_NOTIFIER=1 skips the check. Connection and read timeouts are
two seconds each; current versions and an unavailable registry stay silent. The
check never installs an update or changes your Gemfile or lockfile. Inside Docker there is no
check: the bin/trmnlp script pulls a newer image once a day instead.
Lint findings include a stable snake_case rule ID, severity and source locations. Locations use project-relative paths and one-based line/column numbers, followed by a source excerpt (up to 240 characters). Aggregate checks show their contributing locations; checks for a missing chart setting show the related Highcharts usage. Unused project custom-field values are omitted from excerpts because they can contain credentials.
For CI integrations, use trmnlp lint --format json. It writes one JSON object
with version: 1, passed and an issues array. Each issue has rule_id,
severity, message, locations and, when available, learn_more. Each location
has path, line, column and snippet. A clean report has passed: true and
issues: []. All existing checks retain severity error and the same exit status:
zero when clean, nonzero when findings exist. --quiet suppresses either format.
The inline-style check counts CSS declarations in actual HTML style attributes,
with a limit of six across the view templates and Shared. All property names count,
including custom properties. HTML/Liquid comments, script strings, text,
data-* attributes and <style> blocks are excluded from this inline check.
Liquid in declaration values is counted without rendering the plugin, every branch included.
Filters from custom_filters in .trmnlp.yml exist only in trmnlp. TRMNL does not load them
and outputs the value unfiltered, so no_custom_filters reports each place the markup uses one.
Filters that trmnl-liquid also provides are not reported.
A filter that neither Liquid nor trmnl-liquid defines, such as a typo or one from another
Liquid (Jekyll, LiquidJS), is also output unfiltered on TRMNL, so no_unknown_filters reports it.
To accept a rule's findings, list its rule ID under ignored_lint_rules in .trmnlp.yml.
trmnlp lint then skips that check in both formats, so the CLI and CI agree. An unknown
rule ID is an error that lists the known ones, so a typo does not pass quietly.
trmnlp build renders every view to a static file under _build/ — handy for exporting a snapshot or feeding the output into another pipeline. Run it from inside a plugin project:
trmnlp build # writes _build/full.html, _build/half_horizontal.html, ...
trmnlp build --png # also writes a PNG for each view--png renders each view through the same screenshot pipeline serve uses. By default a PNG is 800×480 at the bit depth declared by the markup's screen--Nbit class (1-bit if none). Override any of those:
trmnlp build --png --color-depth 2| Flag | Purpose |
|---|---|
--png |
Render a PNG per view alongside the HTML |
--width |
PNG width in pixels (default 800) |
--height |
PNG height in pixels (default 480) |
--color-depth |
PNG bit depth — 1-8 — overriding the markup |
--width, --height, and --color-depth apply only with --png. PNG rendering needs Firefox and ImageMagick installed; plain trmnlp build needs neither.
The trmnlp login command saves your API key to ~/.config/trmnlp/config.yml.
If an environment variable is more convenient (for example in a CI/CD pipeline), you can set $TRMNL_API_KEY instead.
trmnlp init and trmnlp clone scaffold a .github/workflows/trmnl.yml
workflow and initialize a Git repository, so a fresh project is ready to push
to GitHub. The workflow runs in GitHub Actions without trmnlp login — set the
TRMNL_API_KEY environment variable and it's used in place of the saved
config. Add it as a repository secret to activate the workflow; it looks like
this:
name: TRMNL
on:
pull_request:
push:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: ruby/setup-ruby@v1
with:
ruby-version: "4.0"
- run: gem install trmnl_preview
- run: trmnlp lint
push:
needs: lint
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: ruby/setup-ruby@v1
with:
ruby-version: "4.0"
- run: gem install trmnl_preview
- run: trmnlp push --force
env:
TRMNL_API_KEY: ${{ secrets.TRMNL_API_KEY }}The lint job gates every pull request — trmnlp lint exits non-zero on
issues, so a failing check blocks the merge. The push job uploads to TRMNL
only on main.
Make sure
src/settings.ymlhas anid.trmnlp pushupdates the plugin with that id; without one it creates a new plugin on every run. Projects made withtrmnlp cloneortrmnlp pullalready have it.
The bin/trmnlp script is provided as a convenience. It will use the local Ruby gem if available, falling back to the trmnl/trmnlp Docker image.
You can modify the bin/trmnlp script to set up environment variables (plugin secrets, etc.) before running the server.
Gem or Docker? Install the gem if you already have Ruby >= 4.0 — it has the fastest startup. Use Docker for zero local setup.
Prerequisites:
- Ruby >= 4.0
- For PNG rendering (optional):
- Firefox
- ImageMagick
gem install trmnl_preview
trmnlp serveTo type trmnlp as with the gem, copy the script from the image onto your PATH:
mkdir -p ~/.local/bin
docker run --rm --entrypoint cat trmnl/trmnlp /app/templates/init/bin/trmnlp > ~/.local/bin/trmnlp
chmod +x ~/.local/bin/trmnlpIt runs each command in the image and pulls a newer image once a day. The steps that run the image
come from the image too, so the script itself does not go out of date. To stay on one release, set
IMAGE in the script to a tag such as trmnl/trmnlp:v0.22.0 (v0.22.0 or later). trmnlp init puts
the same script in each plugin as bin/trmnlp.
Or run the image yourself:
docker run \
--pull always \
--publish 4567:4567 \
--volume "$(pwd):/plugin" \
trmnl/trmnlp serve \
--bind 0.0.0.0--pull always checks the registry on every run and pulls a newer image if one exists, so you don't have to remember to docker pull after each release.
Inside a container, serve binds to 0.0.0.0 automatically (it detects /.dockerenv) so the preview is reachable from your host browser. Outside Docker it binds to 127.0.0.1.
Swap serve for any other command (lint, login, clone, etc.) to run it in a one-off container.
For running multiple commands (login, clone, serve), you can start an interactive shell:
docker run -it \
--pull always \
--publish 4567:4567 \
--volume "$HOME/.config/trmnlp:/root/.config/trmnlp" \
--volume "$(pwd):/plugin" \
--entrypoint /bin/bash \
trmnl/trmnlpThen run commands inside the container:
trmnlp login
trmnlp clone my_plugin 12345
cd my_plugin
trmnlp serveThe config volume ($HOME/.config/trmnlp) persists your API key between sessions.
For a checked-in config — like examples/hn-stories/ uses — a minimal docker-compose.yml:
services:
trmnlp:
image: trmnl/trmnlp
pull_policy: always
command: ["serve"]
ports:
- "4567:4567"
volumes:
- .:/pluginThen docker compose up.
To build the Docker image from source:
git clone https://github.com/usetrmnl/trmnlp.git
cd trmnlp
docker build -t trmnlp .The .trmnlp.yml file lives in the root of the plugin project, and is for configuring the local dev server.
System environment variables are made available in the {{ env }} Liquid varible in this file only. This can be used to safely
supply plugin secrets, like API keys.
All fields are optional.
---
# auto-reload when files change (`watch: false` to disable)
watch:
- src
- .trmnlp.yml
# values of custom fields (defined in src/settings.yml)
custom_fields:
station: "{{ env.ICAO }}" # interpolate $IACO environment variable
# Time zone IANA identifier to inject into trmnl.user; see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
time_zone: America/New_York
# Serverless transforms run automatically when a src/transform.*
# file is present. Set to 'disabled' to turn off.
transform_runtime: enabled
# Optional remote transform daemon URL — when set, transforms POST
# here instead of running locally. Useful for production-fidelity
# testing against a real microVM daemon.
# serverless_daemon_url: https://transforms.your-team.example
# Optional explicit language for src/transform.* (otherwise inferred from extension)
# serverless_language: python
# override variables
variables:
trmnl:
user:
name: Peter Quill
plugin_settings:
instance_name: Kevin Bacon Facts
# rule IDs whose findings `trmnlp lint` drops
ignored_lint_rules:
- no_opacity
# plugin_merge strategy: the plugins this one reads, as "<keyname>_<id>" on TRMNL,
# each mapped to the trmnlp project whose last fetched data stands in for it
merged_plugins:
private_plugin_42: ../weatherAn async_polling plugin gets {{ callback_url }} in its polling url. It points at
trmnlp serve's /callback route, where the API posts merge_variables after answering 202.
This feature is in beta. Please report incorrect behaviour at https://github.com/usetrmnl/trmnlp/issues.
Some private plugins fetch data from a third-party API that requires the user to authorize access first (the OAuth2 authorization code flow). trmnlp can run that flow locally so you can preview the plugin with a real token. It injects the token into your polling request the same way the hosted service does, so a plugin that works locally behaves the same once deployed.
Add the flat oauth_* keys to src/settings.yml. These are the provider definition, and they round-trip through trmnlp push and pull (the hosted service stores them on the plugin setting):
oauth_enabled: "true"
oauth_authorize_url: https://github.com/login/oauth/authorize
oauth_token_url: https://github.com/login/oauth/access_token
oauth_scopes: "read:user user:email"
oauth_pkce_enabled: "true" # optional, default false
# oauth_scope_separator: " " # optional; some providers use ","
# oauth_refresh_url: https://... # optional; defaults to oauth_token_url
# oauth_token_request_auth_method: header # optional; "header" sends the client credentials as HTTP Basic, otherwise they go in the bodyYour OAuth app credentials stay local and are never synced, so set them in your environment:
export TRMNL_OAUTH_CLIENT_ID=your-oauth-app-client-id
export TRMNL_OAUTH_CLIENT_SECRET=your-oauth-app-client-secretPKCE-only providers do not need a client secret.
In your OAuth app on the provider's site, register this redirect URI:
http://localhost:4567/oauth/callback
Match the port if you run trmnlp serve on a different one.
Run trmnlp serve and open the preview. When OAuth is configured but not yet connected, a Connect account banner appears. Click it to authorize in your browser. trmnlp stores the tokens in its cache directory (never in your project) and refreshes them automatically before they expire. Use the Disconnect link to reconnect after changing scopes.
Reference the token in your src/settings.yml polling configuration with the same variables the hosted service exposes:
{{ oauth_access_token }}{{ oauth_token_type }}(defaults toBearer){{ oauth_client_id }}
For example, as a polling header:
Authorization=Bearer {{ oauth_access_token }}
trmnlp can run a transform script (python, ruby, php, or node) against the polled API response before handing data to your Liquid templates — matching the hosted plugin service's behavior.
Drop a file at src/transform.{py,rb,php,js} and define a run(input) function — transforms are enabled by default, so it runs automatically. To turn them off, set transform_runtime: disabled in .trmnlp.yml.
Heads up: because transforms run by default, a plugin you
cloneorpullfrom somewhere else will execute itssrc/transform.*code on your machine the first time you preview it — there is no opt-in prompt. Review a third-party plugin's transform script before serving it, or settransform_runtime: disabled.
The transform receives the polled response on stdin as JSON; whatever run(input) returns becomes the new merge data.
Example src/transform.py:
def run(input):
return {"items": [x["title"] for x in input["data"]]}The trmnlp image bundles python3, node, php, and ruby — no sidecar daemon required.
The transform language comes from the file extension:
| File | Language |
|---|---|
src/transform.py |
python |
src/transform.rb |
ruby |
src/transform.js |
node |
src/transform.php |
php |
trmnlp push uploads the file under its own name, and the hosted service records serverless_language from the extension automatically — you don't need to set it by hand. trmnlp pull / trmnlp clone bring the transform file back under the same name.
For production-fidelity testing against a real microVM daemon, set serverless_daemon_url: in .trmnlp.yml:
transform_runtime: enabled
serverless_daemon_url: https://transforms.your-team.exampleProvide the daemon's bearer token via $TRMNL_SERVERLESS_DAEMON_API_KEY (env-first, mirroring how $TRMNL_API_KEY works for trmnl.com auth):
export TRMNL_SERVERLESS_DAEMON_API_KEY=...
trmnlp serveOr commit a per-project value to .trmnlp.yml as serverless_daemon_api_key: — though the env var is preferred to keep the secret out of version control.
A complete worked example lives at examples/hn-stories/ — a polling plugin that fetches the Hacker News top-stories list, enriches each story via additional HTTPS calls from inside the transform, and renders the result with TRMNL design-system markup across all four sizes. cd examples/hn-stories && docker compose up and you're running it.
The settings.yml file is part of the plugin definition, and is uploaded and downloaded by trmnlp push / pull.
framework_version: pins the TRMNL Design System version this plugin renders against — latest (the default) tracks the newest release, or set a specific version for reproducibility. It lives here rather than in .trmnlp.yml so the value round-trips with the hosted plugin service. The list of known versions is read from the design system's published manifest once per run; if that request fails, the copy bundled in the gem is used. Because either list can lag a release, any well-formed version number is accepted — a version the manifest has not heard of still renders against that version's assets rather than failing — trmnlp serve and build note it with a warning, in case it was a typo.
description: is an optional one-line summary of the plugin, up to 35 characters. trmnlp lint reports anything longer, and trmnlp list shows it next to each plugin name.
recipe_overview: is optional free text for a published recipe, with no length limit. TRMNL shows it on the public recipe page with its line breaks kept, and its first 120 characters feed the page's link preview. An overview of at least 100 words lets the recipe appear in Google and other search engines; trmnlp lint reports one that is shorter. Put credits and links in an About This Plugin field instead. Leave the key out to keep the overview already on TRMNL; an empty value clears it.
See TRMNL documentation for details on this file's contents.
trmnlp test runs the RSpec files in your plugin's tests/ folder through the same pipeline serve and build use, with fake APIs and a fixed clock. trmnlp init starts a plugin with a test and a GitHub workflow that runs it. See Testing Plugins for the API, the matchers, snapshots and running tests in parallel.
To run trmnlp from a checkout of this repo — handy for trying unreleased changes or contributing:
git clone https://github.com/usetrmnl/trmnlp.git
cd trmnlp
bundle install
bundle exec bin/trmnlp serveThe repo pins its Ruby version in .ruby-version — a version manager will pick it up when you cd in. This bin/trmnlp runs the CLI straight from lib/; it's a different script from the gem-or-Docker bin/trmnlp that trmnlp init scaffolds into a plugin project.
To test, run:
bin/rakeSpecs run under SimpleCov; a coverage report is written to coverage/.
Releases are automated. The Release workflow
fires whenever lib/trmnlp/version.rb changes on main, then tags the commit,
publishes the gem to RubyGems, and pushes the multi-arch Docker image. Each step
is idempotent, so the workflow is safe to re-run after a partial failure.
To cut a release:
- Bump the version in
lib/trmnlp/version.rb. - Run
bundle installsoGemfile.lockpicks up the new version. - Commit and merge to
main— the workflow does the rest.
By convention, add a matching CHANGELOG.md entry in the same change.
- Private Plugins: strategies, polling and webhooks
- Liquid 101 and the Framework Design Docs
- Custom plugin form builder: the form fields
src/settings.ymldeclares - Syncing Plugins with GitHub and GitHub Sync
- Recipe best practices and Demo Data for Publishing Plugins
- TRMNL CLI: the rest of your account from the terminal
- MCP Server and our agent skills: build plugins with an AI agent
Bug reports and pull requests are welcome on GitHub at https://github.com/usetrmnl/trmnlp.
The gem is available as open source under the terms of the MIT License.
