trmnl-dev

v2026.09.25

Build, update, and operate TRMNL e-ink private plugins end to end — webhook data updates and merge variables, Liquid markup with Framework 3.1, the per-plugin MCP server (stateless JSON-RPC), an offline preview/screenshot harness, and battery-aware refresh cadence. Use when working with TRMNL devices (OG or TRMNL X), trmnl.com private plugins, webhook payloads, e-ink dashboard markup, the TRMNL MCP API, or debugging clipped, empty, or stale TRMNL renders.

GitHub
Install command
npx skhub add 0juano/trmnl-dev
Markdown
SKILL.md

TRMNL Dev

Field-tested knowledge for building and operating TRMNL private plugins: what the docs say, plus the gotchas the docs don't mention (rendering geometry that clips, MCP quirks, CSS cascade layers, payload byte limits).

Mental model

TRMNL is server-rendered e-ink. The device polls; nothing is pushed to it.

data source ──► webhook POST ──► merge variables ─┐
                                                  ├─► TRMNL renders PNG ──► device wakes,
markup editor / MCP ──► Liquid template ──────────┘    downloads image, sleeps

There are two independent update paths — never confuse them:

  1. Change what the screen says → generate JSON → POST to the plugin webhook. The webhook updates merge variables only; it never changes markup.
  2. Change how the screen looks → edit Liquid markup → save → preview → force refresh. The markup editor never fetches new data by itself.

A webhook post triggers a server-side re-render in ~2 s, but the physical device only shows it on its next wake (default every 15 min). Server-side posts do not wake the device and cost no battery.

Keys and identifiers (don't mix them up)

CredentialLooks likeWhere foundUsed for
Device API keyopaque tokenDevices → Editaccess-token: header on /api/display, /api/current_screen
Account API keyuser_…Account settingsAuthorization: Bearer on account API (devices, playlists)
Plugin Settings UUIDUUID in webhook URLgenerated on first plugin Savewebhook POSTs to /api/custom_plugins/<uuid>
Plugin MCP keyps_mcp_…plugin settings page, right side after Savehttps://trmnl.com/mcp?api_key=…

The MCP key is per-plugin: it can read/write that one plugin's markup, settings, and merge variables — no device, playlist, battery, screenshot, or preview control, and it will not reveal the plugin UUID or webhook URL. For device/playlist work use the account API instead.

Keep all real values in environment variables or an OS keychain, never in committed files. Treat the webhook URL itself as a secret — anyone holding it can write to your screen.

Webhook data updates

Payload shape — everything lives under a top-level merge_variables:

curl "https://trmnl.com/api/custom_plugins/${TRMNL_PLUGIN_UUID}" \
  -H "Content-Type: application/json" \
  -X POST \
  -d '{"merge_variables":{"headline":"Hello TRMNL","items":[{"time":"09:00","title":"Standup"}]}}'

Hard limits (standard tier): 2 KB body, 12 posts/hour (TRMNL+: 5 KB, 30/hour). Faster sends return 429. Plugin "Debug Logs" mode temporarily raises the limit during development.

Rules that keep you out of trouble:

  • Send only reduced display text ({"time","title"}), never raw upstream API output — you'll blow the byte limit and leak data you didn't mean to send.
  • Enforce a local guardrail below the real limit (e.g. 1900 bytes for a 2 KB tier) and make the generator degrade instead of fail: shed rows and render an honest + N more label rather than throwing or silently cropping.
  • Keep template loop limits ≥ generator caps — a template limit: lower than what the generator sends silently drops items.
  • Stateful updates: merge_strategy: "deep_merge" patches nested keys; merge_strategy: "stream" + stream_limit appends to top-level arrays with bounded growth. Stored data must still fit the size limit.
  • GET the same webhook URL to fetch currently stored merge variables.

Markup essentials

Full geometry/CSS details with the bugs they cause: references/framework.md. The non-negotiables:

  • Author at 800x480 always. TRMNL X renders 1040x780 at 4-bit (16 grays) but the framework scales your 800x480 markup automatically (scale_factor: 1.8). Never hardcode the native resolution.
  • One <div class="layout">…</div> per view, optional sibling <div class="title_bar">. Never nest .layout inside .layout. Do not paste .screen/.view wrappers into the TRMNL editor — those are for offline preview only.
  • The framework sizes .layout to screen-h − 2×gap (460 px inside the 10 px bezel; less with a title_bar). If you use a custom root element instead of .layout, it must copy that height: height: calc(var(--screen-h, 480px) - var(--gap, 10px) * 2). height: 100% renders 480 px inside a 460 px box and clips the bottom ~20 px.
  • Omitting the title_bar is legal and often right (it eats ~35 px). Saving markup without one triggers a "Missing title_bar include" warning — expected; ignore it.
  • plugins.css uses CSS cascade layers: a layered !important beats your unlayered !important and inline styles. You cannot out-specify the framework's sizing — adopt its variables (--screen-h, --gap, --full-w) or its classes instead of fighting it.
  • Custom <style> blocks are allowed in private-plugin markup, but TRMNL's in-app AI agent enforces "no custom styles" and will flatten your design if asked to "improve" it. Keep a local file as the source of truth.
  • E-ink legibility: trust the physical device over any browser preview. When in doubt — bigger type, 2 px rules (1 px hairlines vanish at distance), font-feature-settings: "tnum" 1 for aligned times, and for screens read at a distance don't shrink body type below ~19 px — use two columns instead.

Per-plugin MCP

Full tool table, curl recipes, and every gotcha: references/mcp.md. The essentials:

  • Endpoint https://trmnl.com/mcp?api_key=… is stateless JSON-RPC over HTTP — call tools/list / tools/call directly, no initialize handshake.
  • Tool names are PascalCase with a Tool suffix (MarkupsWriteTool), not the in-app agent's snake_case names. snake_case returns Tool not found.
  • MarkupsWriteTool takes {size, content} — passing markup instead of content returns an unhelpful -32603 Internal error.
  • MCP writes markup blind (no screenshot/preview tool exists). Safe write flow: read live → back up → write → read back → unescape → compare with your local source. Verify visually in the editor previewer or offline harness.

Offline preview (no account round-trips)

Render your Liquid template locally with sample data inside TRMNL's official CSS/JS wrapper, then screenshot the .screen element (exactly 800x480). Complete recipe and pitfalls: references/preview.md.

Iterate offline until the layout is right; only then push markup. Include worst-case sample payloads (max rows, longest titles) — busy days are where layouts break, not calm ones.

Battery and refresh cadence

  • Plugin refresh (server) and device wake (battery) are separate. Webhook posts are free; device wakes cost battery (WiFi + fetch + e-paper refresh).
  • Sleep Mode is the biggest easy win: sleeping 22:00–06:00 at a 15-min cadence avoids ~32 wakes/day.
  • Match cadence to content: dashboards fed by daily/hourly data rarely need 15-min refresh all day. A sane profile: 15–30 min during the morning rush, 60 min midday, 30–60 min in the evening, sleep overnight.
  • /api/display (device key) advances the playlist — don't poll it for monitoring; use /api/current_screen sparingly instead.

Troubleshooting

SymptomFix
Preview renders emptyPOST a tiny payload first; confirm strategy = Webhook; payload under merge_variables; plugin saved once (UUID exists)
Bottom of screen clippedcustom root missing the calc(var(--screen-h) - var(--gap)*2) height; or a title_bar you forgot to budget for
Physical device looks worse than previewtrust the device: bigger type, fewer display fonts, explicit grid columns, thicker rules
Webhook 429over posts/hour cap — batch updates; enable Debug Logs during dev
Items missing from rendertemplate limit: below what the payload contains; or payload exceeded the byte cap and the generator shed rows
MCP Tool not foundyou used snake_case; use PascalCase + Tool suffix
MCP -32603 Internal error on writeyou passed markup; the argument is content
MCP readback never matches local filethe envelope escapes \n/\"/\\ — unescape before diffing
Editor save "Missing title_bar include" warningexpected when intentionally omitting the title bar; ignore

Doc links

Discovery
Tags

No tags published for this skill.

Version
Latest version metadata

Version

v2026.09.25

Published

Sep 25, 2026

Category

Uncategorized

License

Not specified

Source path

trmnl-dev

Default branch

main

Latest commit

27ffbd1

Tree SHA

211bfdd