Skip to content

Custom Templates ​

Custom templates let you control the system prompt formatting for an entity. By default, entities use the built-in DEFAULT_TEMPLATE. A custom template replaces this entirely.

Powered by Nunjucks with runtime security patches.

Editing ​

/edit <entity> type:Template

Submit an empty template to clear it and revert to default formatting.

Syntax ​

Full Nunjucks template syntax:

{{ expr }}                                    — expression output
{% if expr %}...{% elif expr %}...{% else %}...{% endif %}
{% for var in expr %}...{% else %}...{% endfor %}
{% extends "entity-name" %}                  — template inheritance (loads another entity's template)
{% block name %}...{% endblock %}             — named blocks (rendered inline, or overrides parent blocks)
{{ value | filter }}                          — pipe filters
{%- tag -%}                                  — whitespace control
{# comment #}

Operators ​

Nunjucks operators available in expressions:

OperatorDescription
andLogical AND
orLogical OR
notLogical NOT
inContainment test
isIdentity test
~String concatenation
**Exponentiation
//Floor division

Standard comparison and arithmetic operators also work: ==, !=, <, >, <=, >=, +, -, *, /, %.

Whitespace Control ​

Add - to strip whitespace around block tags:

{%- if true -%}   — strips whitespace before and after
{%- for x in arr -%}

By default, trimBlocks and lstripBlocks are enabled — the newline after a block tag and leading whitespace on block-only lines are automatically stripped.

Filters ​

FilterUsageDescription
default{{ x | default("none") }}Return default if null/undefined/empty
length{{ arr | length }}Array or string length
join{{ arr | join(", ", "name") }}Join array, optional attr pluck (100KB cap)
first{{ arr | first }}First element
last{{ arr | last }}Last element
upper{{ s | upper }}Uppercase
lower{{ s | lower }}Lowercase
trim{{ s | trim }}Strip whitespace
nl2br{{ s | nl2br }}Identity (no HTML conversion)
int{{ s | int }}Parse as integer
float{{ s | float }}Parse as float
abs{{ n | abs }}Absolute value
round{{ n | round(2) }}Round to precision
reverse{{ arr | reverse }}Reverse array/string
sort{{ arr | sort }}Sort array
batch{{ arr | batch(3) }}Group into n-sized chunks
selectattr{{ arr | selectattr("responding") }}Filter items where attr is truthy
rejectattr{{ arr | rejectattr("responding") }}Filter items where attr is falsy

Template Context ​

All standard expression context variables are available (see ExprContext), plus:

VariableTypeDescription
entitiesArray<{id, name, facts, responding}>All entities — responding first, then referenced/user. Each has a responding boolean. Facts have toString() → join('\n').
othersArray<{id, name, facts, responding}>Backward-compat alias: non-responding entities only
respondersRecord<number, entity>ID→entity dict for responding entities (for message role lookup)
memoriesRecord<number, string[]>Entity ID to memory strings (arrays have toString() → join('\n'))
entity_namesstringComma-separated names of responding entities
freeformbooleanTrue if any entity has $freeform
historyArray<{author, content, author_id, created_at, is_bot, role, embeds, stickers, attachments, components}>Structured message history (chronological)
char{id, name, facts}First responding entity (toString() → name)
user{id, name, facts}User entity from others (toString() → name, defaults to {name: "user"})
_single_entitybooleanTrue when exactly one entity is responding

Use entities \| selectattr("responding") to get responding entities, entities \| rejectattr("responding") for non-responding.

Structured Messages ​

The history variable provides the raw message history as structured objects:

FieldTypeDescription
authorstringDisplay name of the message author
contentstringMessage content
author_idstringDiscord user ID of the author
created_atstringISO timestamp of the message
is_botbooleanWhether the author is a Discord bot
entity_idnumber | nullEntity ID if sent by a responding entity, null otherwise. Use responders[msg.entity_id] to determine message role.
embedsEmbedData[]Discord embed data (see below)
stickersArray<{id, name, format_type}>Sticker data (format_type: 1=PNG, 2=APNG, 3=Lottie, 4=GIF)
attachmentsAttachmentData[]File attachments (see below)
componentsDiscordComponentData[]Discord Components v2 data (see below)
toJSON()stringJSON string of the full message object

All history entries and their embeds, stickers, attachments, and components arrays have a toJSON() method that returns a JSON string, for use in templates:

{{ msg.toJSON() }}               {# Full message as JSON #}
{{ msg.embeds.toJSON() }}        {# Just embeds as JSON #}
{{ msg.attachments.toJSON() }}   {# Just attachments as JSON #}
{{ msg.components.toJSON() }}    {# Just components as JSON #}

Embed Fields ​

FieldTypeDescription
titlestring?Embed title
typestring?Embed type ("rich", "image", "video", "gifv", "article", "link")
descriptionstring?Embed description
urlstring?Embed URL
timestampnumber?Embed timestamp
colornumber?Color code (integer)
footer{text, icon_url?}?Footer information
image{url, height?, width?}?Image information
thumbnail{url, height?, width?}?Thumbnail information
video{url?, height?, width?}?Video information
provider{name?, url?}?Provider information
author{name, url?, icon_url?}?Author information
fieldsArray<{name, value, inline?}>?Embed fields

Attachment Fields ​

FieldTypeDescription
filenamestringName of the attached file
urlstringSource URL of the file
content_typestring?MIME type
titlestring?File title
descriptionstring?Alt text / description (max 1024 chars)
sizenumber?File size in bytes
heightnumber?Image/video height in pixels
widthnumber?Image/video width in pixels
ephemeralboolean?Whether the attachment is ephemeral
duration_secsnumber?Audio duration for voice messages

Component Fields (Components v2) ​

Bots using Discord's Components v2 system (flags: 32768) send visual content via a recursive component tree instead of embeds. Component data is stored in camelCase matching the Discordeno transformer output.

FieldTypeDescription
typenumberComponent type (9=Section, 10=TextDisplay, 12=MediaGallery, 14=Separator, 17=Container, etc.)
contentstring?Text content (for TextDisplay)
componentsDiscordComponentData[]?Nested child components
itemsArray<{media?, description?, spoiler?}>?Items array (for MediaGallery)
media{url}?Media reference
accessoryDiscordComponentData?Accessory component (for Section)
accentColornumber?Container accent color
spoilerboolean?Whether content is spoilered
labelstring?Button/option label
urlstring?Link URL
stylenumber?Button style
dividerboolean?Show divider line (for Separator)
spacingnumber?Spacing size (for Separator)

Components are best accessed via toJSON() since the structure is recursive and varies by type.

send_as Macro ​

The send_as(role) macro designates message roles in the LLM conversation. It is automatically injected into templates at render time — you don't need to define it.

Use {% call send_as(role) %}...{% endcall %} to wrap content that should be sent as a specific role:

RoleDescription
"system"System-role message
"user"User messages
"assistant"Entity/character messages

Unmarked text (outside any send_as call) automatically becomes system-role messages. This means entity definitions and instructions can be written as plain template text.

{#- Entity defs (unmarked → system-role) -#}
You are {{ entities[0].name }}.
{{ entities[0].facts }}

{#- History (send_as → proper roles) -#}
{% for msg in history %}
{% call send_as("assistant" if responders[msg.entity_id] else "user") -%}
{{ msg.author }}: {{ msg.content }}
{%- endcall %}
{% endfor %}

Behavior:

  • send_as calls produce messages with the specified role
  • Empty send_as calls (whitespace-only) are filtered out
  • Unmarked text between, before, or after send_as calls → system-role messages
  • No send_as calls at all → entire output is a single system message (legacy behavior)

Blocks are organizational only: {% block name %} is for template inheritance — block names have no role semantics. Content inside blocks renders as unmarked text unless wrapped in send_as.

Security: send_as markers use cryptographic nonce markers (256-bit random hex) at render time. Template context values are strings, not template code — Discord user messages containing marker-like text cannot trigger the parser.

Attachment Functions ​

These functions emit attachment markers (HATT) that are resolved after rendering to inline image/file content parts for vision-capable models, or text fallbacks for others.

attach(url, contentType?) ​

Emits an attachment marker for the given URL. The model receives the attachment as an inline image/file if the provider supports it, or a text reference otherwise.

ParameterTypeDescription
urlstringURL of the attachment
contentTypestring?MIME type or base type. Accepts full types ("image/jpeg", "application/pdf") or base types ("image" when exact subtype is unknown). Defaults to "application/octet-stream" if omitted.
{# Known subtype #}
{{ attach(attachment.url, attachment.content_type) }}

{# Unknown subtype — known to be an image #}
{{ attach(embed.image.url, "image") }}

{# Document #}
{{ attach(file.url, "application/pdf") }}

Note: Discord CDN images (cdn.discordapp.com, media.discordapp.net) are fetched and sent as inline base64 data. External URLs are passed as-is (the model fetches them directly).

parse_emojis(content) ​

Replaces Discord custom emoji syntax (<:name:id>, <a:name:id>) with the original reference immediately followed by an inline image attachment. Vision-capable models see both the text reference and the image.

{{ parse_emojis(msg.content) }}

Animated emoji (<a:...>) use .gif, static emoji use .webp (Discord always serves WebP at .webp URLs regardless of upload format).

sticker_url(sticker) / sticker_media_type(sticker) ​

Return the CDN URL and IANA media type for a sticker, or empty string for unsupported formats (Lottie, unknown). Use with attach() to give the template full control over rendering:

{%- for sticker in msg.stickers %}
[sticker: {{ sticker.name }}]{% if sticker_url(sticker) %}{{ attach(sticker_url(sticker), sticker_media_type(sticker)) }}{% endif %}
{%- endfor %}
format_typeFormatsticker_urlsticker_media_type
1PNGstickers/{id}.pngimage/png
2APNGstickers/{id}.pngimage/png
3Lottie""""
4GIFstickers/{id}.gifimage/gif

Template Inheritance ​

Templates can inherit from other entities' templates:

{% extends "base-entity-name" %}

{% block custom_section %}
Custom content here
{% endblock %}

The parent template is loaded by looking up the entity by name and reading its template. The child template overrides parent {% block %} sections. Nunjucks provides built-in circular inheritance detection. Child templates in an inheritance chain inherit the send_as macro from their root parent.

Note: The parent template runs with the same context as the child (entities, history, etc. come from the current render call, not from the parent entity).

Per-Entity System Prompt ​

Each entity can have a custom system prompt template (separate from the main template):

/edit <entity> type:System Prompt

This controls the AI SDK system parameter — the top-level system instruction the LLM sees before any messages. Empty = use global default (currently empty).

Full Prompt Control ​

Templates produce structured message arrays using send_as for role designation. Unmarked text becomes system-role messages. The built-in default template uses responders[msg.entity_id] to assign assistant role only to responding entities, and user role to everything else.

{#- Unmarked text → system-role message -#}
You are {{ entities[0].name }}.
{{ entities[0].facts }}
{#- History → proper roles via send_as -#}
{% for msg in history %}
{% call send_as("assistant" if responders[msg.entity_id] else "user") -%}
{{ msg.author }}: {{ msg.content }}
{%- endcall %}
{% endfor %}

For-loop Variables ​

Inside {% for %} blocks, Nunjucks provides the loop variable:

VariableDescription
loop.index1-based index
loop.index00-based index
loop.firstTrue for first iteration
loop.lastTrue for last iteration
loop.lengthTotal number of items

Example ​

{# Custom template — entity defs are unmarked (→ system role) #}
{% set responding = entities | selectattr("responding") %}
{% for entity in responding %}
You are {{ entity.name }}.

{% for fact in entity.facts %}
- {{ fact }}
{% endfor %}

{% if memories[entity.id] %}
Memories:
{% for memory in memories[entity.id] %}
- {{ memory }}
{% endfor %}
{% endif %}
{% endfor %}

{% for entity in entities | rejectattr("responding") %}
{{ entity.name }} is nearby.
{% for fact in entity.facts %}
- {{ fact }}
{% endfor %}
{% endfor %}

{% if freeform %}
Write naturally with all characters.
{% endif %}

{# Message history with proper roles via send_as #}
{% for msg in history %}
{% call send_as("assistant" if responders[msg.entity_id] else "user") -%}
{{ msg.author }}: {{ msg.content }}
{%- endcall %}
{% endfor %}

Array toString ​

Facts and memories arrays have toString() overrides that join with newlines, so {{ char.facts }} outputs the same as {{ char.facts | join("\n") }}:

{{ char.name }}'s facts:
{{ char.facts }}

Using Filters ​

{{ entity_names | upper }}
{{ entities | length }} entities responding
{% for entity in entities %}
{{ entity.facts | join("\n") }}
{% endfor %}
{% for batch in others | batch(3) %}
Group: {% for e in batch %}{{ e.name }}{% if not loop.last %}, {% endif %}{% endfor %}
{% endfor %}

Whitespace Control Example ​

{%- for entity in entities -%}
{{ entity.name }}: {{ entity.facts | join(", ") }}
{%- endfor -%}

Image Output ​

Some LLM models can generate images directly. When an entity uses an image-generation model, any images in the response are automatically sent as Discord attachments — no template changes needed.

Supported models:

ProviderModelSpec
GoogleGemini 2.5 Flash Imagegoogle:gemini-2.5-flash-image
GoogleGemini 2.0 Flash Image Generationgoogle:gemini-2.0-flash-image-generation
GoogleGemini 3 Pro Image Previewgoogle:gemini-3-pro-image-preview
GoogleGemini 3.1 Flash Image Previewgoogle:gemini-3.1-flash-image-preview
GoogleImagen 4.0google:imagen-4.0-generate-001
GoogleImagen 4.0 Ultragoogle:imagen-4.0-ultra-generate-001
GoogleImagen 4.0 Fastgoogle:imagen-4.0-fast-generate-001
Google VertexImagen 3.0google-vertex:imagen-3.0-generate-001
Google VertexImagen 4.0google-vertex:imagen-4.0-generate-001
xAIGrok 2 Imagexai:grok-2-image
xAIGrok Imagine Imagexai:grok-imagine-image
xAIGrok Imagine Image Proxai:grok-imagine-image-pro
OpenAIDALL-E 3openai:dall-e-3
OpenAIGPT Image 1openai:gpt-image-1
Amazon BedrockNova Canvasamazon-bedrock:amazon.nova-canvas-v1:0
DeepInfraFLUX 1.1 Prodeepinfra:black-forest-labs/FLUX-1.1-pro
FireworksFLUX 1 Devfireworks:accounts/fireworks/models/flux-1-dev-fp8
Together AIFLUX 1.1 Protogetherai:black-forest-labs/FLUX.1.1-pro

Set the model with $model on the entity:

$model google:gemini-2.0-flash-image-generation

Generated images are sent as file attachments on the first response chunk. They can be combined with text — the model may return both.

Security ​

Templates use Nunjucks with runtime security patches:

  • Property access: constructor, __proto__, prototype, and related properties are blocked (return undefined)
  • Method calls: .apply(), .bind(), .call() are blocked; .matchAll() is blocked
  • Regex validation: .match(), .search(), .replace(), .split() validate patterns against ReDoS
  • Memory limits: .repeat(), .padStart(), .padEnd(), .replaceAll(), .join() are capped at 100KB output
  • Loop limit: 1000 iterations per for-loop
  • Output limit: 1MB maximum template output

Limits ​

  • Loop iterations: 1000 per for-loop
  • Output size: 1MB maximum
  • String methods: 100KB per method call output

Grouping Behavior ​

Entities with different templates get separate LLM calls. Entities with the same template (including null/default) share a call as before. This prevents one entity's template from controlling how another entity's facts are presented.

Deferred Features ​

The following Nunjucks feature is not yet available:

  • {% include %} — template inclusion

Known Limitations ​

Templates are per-entity and control the entire system prompt. A template on one entity could manipulate how other entities' facts are presented in the same LLM call (when entities share the same template). Mitigation:

  • Template-based grouping separates entities with different templates
  • Only the entity owner/editors can set a template (same permission model as facts)
  • Entities sharing a template are presumed to be managed by the same owner

Try it out

Test template rendering interactively in the Template Rendering Playground.

Released under the MIT License.