Agent Skills: TRMNL Dev

>

UncategorizedID: 0juano/agent-skills/trmnl-dev

Install this agent skill to your local

pnpm dlx add-skill https://github.com/0juano/agent-skills/tree/HEAD/trmnl-dev

Skill Files

Browse the full folder contents for trmnl-dev.

Download Skill

Loading file tree…

trmnl-dev/SKILL.md

Skill Metadata

Name
trmnl-dev
Description
>

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)

| Credential | Looks like | Where found | Used for | |---|---|---|---| | Device API key | opaque token | Devices → Edit | access-token: header on /api/display, /api/current_screen | | Account API key | user_… | Account settings | Authorization: Bearer on account API (devices, playlists) | | Plugin Settings UUID | UUID in webhook URL | generated on first plugin Save | webhook POSTs to /api/custom_plugins/<uuid> | | Plugin MCP key | ps_mcp_… | plugin settings page, right side after Save | https://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

| Symptom | Fix | |---|---| | Preview renders empty | POST a tiny payload first; confirm strategy = Webhook; payload under merge_variables; plugin saved once (UUID exists) | | Bottom of screen clipped | custom 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 preview | trust the device: bigger type, fewer display fonts, explicit grid columns, thicker rules | | Webhook 429 | over posts/hour cap — batch updates; enable Debug Logs during dev | | Items missing from render | template limit: below what the payload contains; or payload exceeded the byte cap and the generator shed rows | | MCP Tool not found | you used snake_case; use PascalCase + Tool suffix | | MCP -32603 Internal error on write | you passed markup; the argument is content | | MCP readback never matches local file | the envelope escapes \n/\"/\\ — unescape before diffing | | Editor save "Missing title_bar include" warning | expected when intentionally omitting the title bar; ignore |

Doc links

  • Docs index: https://docs.trmnl.com/go/llms.txt
  • Webhooks: https://docs.trmnl.com/go/private-plugins/webhooks.md
  • Templates: https://docs.trmnl.com/go/private-plugins/templates.md
  • Framework 3.1: https://trmnl.com/framework/docs/3.1/v3_overview.md
  • TRMNL X guide: https://trmnl.com/framework/docs/3.1/trmnl_x_guide
  • Native plugin markup examples: https://github.com/usetrmnl/plugins/