Templates
Wherever an Elicitly tool lets you shape what the user sees with your own markup, the template is LiquidJS, rendered by the same engine with the same rules:
- Inline in the conversation (Free and Pro) —
elicit_display’stemplate, rendered as an MCP App. - On a Pro review page —
elicit_approval’sdisplayTemplate,elicit_proposal’sdisplayTemplate(a header above the diff grid), andelicit_selection’srowTemplate/headingTemplate/footerTemplate/hoverTemplate.
This page is that shared contract; each tool’s page covers the specifics of what it renders and where.
The engine
Section titled “The engine”Templates are LiquidJS (the JavaScript implementation of
Shopify’s Liquid). You get the usual Liquid toolkit — {{ output }},
{% if %} / {% unless %} / {% for %}, filters like {{ x | default: "…" }}
and {{ x | upcase }} — evaluated against a context the tool provides:
{{ context.* }} for approval and proposal payloads, {{ option.* }} for
selection rows.
Authoring multi-line templates
Section titled “Authoring multi-line templates”Templates travel to the API as JSON strings, and JSON has no multi-line string syntax — but you never need to hand-write a template as one long line. Author it as a multi-line string in your language (a JS/TS template literal, a Python triple-quoted string) and let JSON serialization handle the escaping:
const displayTemplate = ` <strong>Deploy {{ context.service }} {{ context.version }}</strong> <table> {% for c in context.changes %} <tr><td>{{ c.file }}</td><td>+{{ c.added }}</td><td>−{{ c.removed }}</td></tr> {% endfor %} </table>`Newlines and indentation inside a template are legal and insignificant — the
output is HTML, where whitespace collapses. (If a stray newline ever matters,
Liquid’s whitespace control
tags {%- -%} trim it.) For long templates, keep the template in its own file
in your repo and read it in when building the call.
Truthiness is JavaScript-style
Section titled “Truthiness is JavaScript-style”We run LiquidJS with its jsTruthy option enabled, so {% if %},
{% unless %}, and and/or follow JavaScript truthiness:
| Value | Truthy? |
|---|---|
false, null, undefined |
falsy |
0 |
falsy |
"" (empty string) |
falsy |
[] (empty array), {} (empty object) |
truthy |
| any non-empty string, non-zero number, populated array/object | truthy |
So {% if option.count %} is what a JavaScript developer expects — 0 skips the
block, an empty string skips the block, a missing field skips the block:
{% if option.logo %}<img src="{{ option.logo }}">{% endif %}{% if option.discount %}Save {{ option.discount }}%{% endif %} {# 0 → nothing #}This matches JavaScript and Nunjucks.
It deliberately differs from Shopify’s default Liquid (where 0 and "" are
truthy — a common source of surprise). One thing to know: an empty array is
truthy here (as in JavaScript and Nunjucks — but unlike Jinja2/Python, where
empty collections are falsy). To branch on whether a list has items, check its
size explicitly rather than the array itself:
{% if option.tags.size > 0 %}...{% endif %} {# not: {% if option.tags %} #}The default filter follows the same truthiness, so a value of 0 (as well as
null, "", or a missing field) triggers the fallback:
{{ option.label | default: option.id }} {# empty/missing label → falls back to id #}Values are escaped
Section titled “Values are escaped”Interpolated data ({{ context.… }}, {{ option.… }}) is HTML-escaped, so
payload or option content can’t break the layout or inject markup — your
template’s own tags render as-is, but the values dropped into them are inert text.
Templates are rendered server-side and their output is displayed inside a
sandboxed, null-origin iframe. The exact sandbox differs by flow — the
selection table runs with no scripting
(sandbox="allow-forms allow-popups allow-popups-to-escape-sandbox") so its
native radio/checkbox controls can’t be spoofed, while approval and proposal
display templates are display-only
(sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox", no
same-origin access) — see Selection Flow and
elicit_approval for the
details. elicit_display renders inside the host’s MCP Apps sandbox, in a
nested frame with the same display-only sandbox as approval templates.
Links and images
Section titled “Links and images”Two things behave differently inside a sandboxed iframe than they do on an ordinary page. Both bite silently — nothing is logged and nothing is styled as an error — so they’re worth getting right the first time.
Always give links target="_blank"
Section titled “Always give links target="_blank"”A link in any review-page template — a selection rowTemplate or
hoverTemplate, an approval or proposal displayTemplate — must open in a
new tab. (elicit_display routes link clicks to the user’s browser through the
host, so it doesn’t need this — but it does no harm.)
<td><a href="{{ option.website }}" target="_blank" rel="noopener">Website</a></td>Without target="_blank", the click navigates the iframe itself. The
option table (or the rendered payload) is replaced by the destination site, and
the reviewer has no way back to the decision controls short of reloading the
page. The sandbox permits a frame to navigate itself, so nothing stops this.
rel="noopener" is good hygiene, though the sandbox already prevents the opened
page from reaching back into the review page.
Images load from any host — there is no allowlist
Section titled “Images load from any host — there is no allowlist”(This section is about the review pages. Templates shown inline by
elicit_display run under the host’s MCP Apps
sandbox with an allowlist of their own (jsDelivr, plus your organization’s
allowed origins on Pro) — see
Images, scripts, and styles.)
<img src="https://…"> in a template fetches normally; the sandbox restricts
scripting and origin access, not subresource loading, and Elicitly sets no
Content-Security-Policy that would limit image hosts. There is nothing to
register or green-light.
That also means a broken image is a plain HTTP failure — a 404, a 403 from a
host that blocks hotlinking, or an expired signed URL — not something the review
page did. Check the URL’s status directly (curl -I) or in your browser’s
Network panel. Guard optional images so a missing field renders nothing rather
than a broken-image glyph:
{% if option.logo %}<img src="{{ option.logo }}" alt="" width="24" height="24">{% endif %}If your image host blocks hotlinking, serve the images from somewhere that
doesn’t, or inline small ones as a data: URI (they count against the 32 KB
template cap).
Limits and fallback
Section titled “Limits and fallback”- Size: each template is capped at 32 KB (32,768 characters) — room for
inline SVG icons and small
data:images. - Cost: rendering runs under parse-length, render-time, and memory caps; there is no access to the network, the filesystem, or anything outside the context you pass.
- Own-property only: templates read your data’s own properties, not inherited or prototype fields.
- Fallback: for
elicit_display, a template that fails to parse or render returns a tool error carrying the Liquid message, so the agent can fix it. On a review page, the page falls back to a safe default (the pretty-printed JSON for approvals, a plainid+labeltable for selections, no header above the grid for proposals) — a bad template never blocks a request.
See also
Section titled “See also”elicit_display—template, rendered inline as an MCP App.- Approval Flow and
elicit_approval—displayTemplate. - Proposal Flow and
elicit_proposal—displayTemplate, the header above the diff grid. - Selection Flow and
elicit_selection— row/heading/footer/hover templates andcss.