Variables
Anywhere you can type text, you can type {something} and FlaviBot replaces
it when the rule runs.
Welcome {user.mention} to {server.name}!
Variable names follow a tree. Everything about the member sits under user.
or member., everything about the message under message., and so on, so
once you know one, you can guess the rest.
Always available
{user}, {user.mention}, {user.id}, {channel.mention}, {channel.id},
{server.id}.
Triggers with no member behind them (a scheduled rule, a channel event) do
not offer the user. family, because there is nobody to name.
From the trigger
Each trigger adds its own. A few examples:
| Trigger | Adds |
|---|---|
message_create | {message.content}, {message.id}, {mentions.count} |
message_edit | {message.content} (after), {message.old_content} (before) |
message_delete | {message.content}, {message.author.mention} |
reaction_add | {reaction.emoji}, {reaction.name}, {reaction.count} |
member_join | {member.mention}, {member.account_age_days}, {member.join_type} |
member_leave | {member.mention}, {member.time_in_server}, {member.roles} |
boost_end | {member.mention}, {boost.duration} |
ticket_close | {ticket.duration}, {ticket.closer.mention}, {ticket.claimed_by.mention} |
level_up | {level.new}, {level.old}, {user.display_name}, {user.avatar_url} |
music_track_start | {track.name}, {track.author}, {track.duration} |
automod_action | {message.content}, {automod.action}, {message.url} |
select_menu | {select.value}, {select.values}, {select.count} |
slash_command | {command.name} plus every declared option as {options.<name>} |
custom_event | {event.name} plus every key of the payload |
scheduled | {schedule.is_first_run}, {schedule.run_count}, {schedule.now_unix}, {schedule.next_unix} |
The editor lists the ones your trigger actually provides, so you never have to memorise them. If a variable is offered, it resolves: a name in that list that the bot could not fill would be a bug, not a blank.
A rule that belongs to a group also
gets {panel.message_id} / {panel.channel_id} (the panel its own button sits
on) and {group.panel_message_id} / {group.panel_channel_id} (the group's
primary panel), which is how a rule edits the message its group published.
From earlier steps
{steps.2.value} is the value the second step returned. This is how a
counter reaches the message that announces it.
Reshaping a value
A variable gives you what it has, which is not always what you want to read.
A delay arrives as 300. A member typed their name in capitals. A message is
four paragraphs long and you only want the first line in a log.
Add a transformer after the variable name, separated by a colon:
Hello {user.name:trim:uppercase}
Banned in {steps.3.deferred_seconds:duration}
They said: {message.content:truncate(80):escape}
Transformers apply left to right, so the chain reads in the order it happens.
Start typing a colon inside a {token} in any text field and the editor lists
them, with an example of what each one does.
Text
| Write | What it does | Example |
|---|---|---|
:uppercase | Puts the whole value in capitals. | ada -> ADA |
:lowercase | Puts the whole value in lower case. | Ada -> ada |
:title | Capitalises the first letter of every word. | ada lovelace -> Ada Lovelace |
:trim | Removes the spaces around the value. | ' ada ' -> 'ada' |
:truncate(40) | Cuts the value to a maximum length, on a word when it can. | truncate(10) on a long sentence -> 'the quick…' |
:default(nobody) | Writes your text instead when the value came back empty. | default(nobody) on an empty nickname -> nobody |
:replace(old|new) | Swaps every occurrence of some text for another, exactly as written. Leave the right side empty to remove the text. | replace(-| ) on a-b-c -> a b c |
Numbers
| Write | What it does | Example |
|---|---|---|
:number | Adds thousands separators so a big number stays readable. | 1000000 -> 1,000,000 |
:round | Rounds a decimal to the nearest whole number. | 12.7 -> 13 |
Dates and durations
| Write | What it does | Example |
|---|---|---|
:duration | Turns a number of seconds into words. | 300 -> 5 minutes |
:from_now | Turns a number of seconds into a live Discord countdown that keeps ticking after the message is posted. | 300 -> in 5 minutes |
:relative | Shows a date as how long ago or how far ahead it is, in the reader's own language. | a timestamp -> 3 days ago |
:datetime | Shows a date with its time, in each reader's own timezone. | a timestamp -> 1 January 2026 00:00 |
:date | Shows a date without its time, in each reader's own timezone. | a timestamp -> 1 January 2026 |
:time | Shows the time of day, in each reader's own timezone. | a timestamp -> 00:00 |
:plus(20m) | Moves a moment forward, by a number of seconds or by 30s, 20m, 2h or 1d. Combine with a countdown to write a deadline: the trigger's own time, plus the delay, read as a countdown. | {trigger.timestamp:plus(20m):relative} -> in 20 minutes |
:minus(1h) | Moves a moment backward, by a number of seconds or by 30s, 20m, 2h or 1d. | {trigger.timestamp:minus(1h):time} -> one hour before |
Lists
A stored list arrives as its raw form — ["ada", "bob"] — which is almost never what you want in a message.
| Write | What it does | Example |
|---|---|---|
:join | Writes the entries out separated by whatever you choose. Leave it empty for a comma and a space, or write \n to put each entry on its own line. | ["ada", "bob"] -> ada, bob |
:nth(0) | Takes a single entry. Counts from 0, and a negative number counts back from the end, so nth(-1) is the last one. | ["ada", "bob"] -> ada |
:count | How many entries there are. | ["ada", "bob"] -> 2 |
These also work on a value that is not a stored list: a line-separated or comma-separated text is read as one entry per line or per comma, and a plain value counts as a single entry.
An empty list, a missing variable or a position that does not exist all give you nothing back rather than an error, so pair them with :default when you need a fallback:
The next in line is {vars.queue:nth(0):default(nobody yet)}.
\n in a transformer's value is a line break, so a list can go out one entry per line, or as a bulleted list:
Suggestions so far:
{vars.suggestions:join(\n):default(none yet)}
- {vars.suggestions:join(\n- )}
Posting a whole list into a message can get long — Discord cuts a message off at 2000 characters, and an entry someone typed can contain a mention. {vars.queue:join:truncate(1900):escape} keeps both in check.
Comparing two lists
A Set variable step compares two lists with one of its transforms:
| Transform | What it keeps | Example |
|---|---|---|
| Keep if in list | The entries that are also in the other list | ["1","2","3"] and ["3","4","5"] -> ["3"] |
| Remove if in list | The entries the other list does not have | ["1","2","3"] and ["3","4","5"] -> ["1","2"] |
The step's text is the first list, the transform's field the other one, and both can be variables. To find what two stored lists have in common:
- Read data: variable
wanted, published asa. - Read data: variable
owned, published asb. - Set variable: name
common, text{vars.a.value}, transform Keep if in list with{vars.b.value}. - Send message, run only if
{vars.common.value:count}is over0:
Already yours: {vars.common.value:join}
Both sides are read like the list transformers above read a value: a stored list, or one entry per line or per comma. Entries are compared without their surrounding spaces, and Ignore case makes Apple and apple the same entry. The result keeps the first list's order, lists each entry once, and is itself a list, so :join, :count and :nth work on it and a Store data step can keep it. With nothing left it is []: :count gives 0 and :join gives nothing, so {vars.common.value:join:default(nothing in common)} covers that case in a message.
Picking one at random
Read the list with a Read data step, then use the alias it publishes:
| Write | What you get |
|---|---|
{vars.roster.random} | One entry, picked at random |
{vars.roster.random_index} | Which position it came from, counting from 0 |
{vars.roster.first} | The first entry |
{vars.roster.last} | The last entry |
The draw happens once, when the Read data step runs. Every later step and every later condition in that run sees the same entry, so you can check it in a condition and then name it in the message without the two disagreeing.
These four are also the only form that works in a field expecting an id — giving the drawn member a role, for instance. A field like that takes a plain {vars.roster.random} and refuses anything carrying a : transformer.
Safety
| Write | What it does | Example |
|---|---|---|
:escape | Cancels Discord formatting and mass pings inside text a member wrote. Worth adding whenever you repeat someone's message. | bold @everyone -> **bold** (no ping) |
:encode | Encodes the value so it can go inside a web address, for a search link or an HTTP request. | rock & roll -> rock%20%26%20roll |
:json | Escapes quotes, backslashes and line breaks so the value can sit between the quotes of a JSON text. | say "hi" -> say \"hi\" |
Links and JSON built from what a member typed
A value pasted into a link or a piece of JSON has to survive the characters
people type. In a link, &, #, ? and spaces end the parameter or cut
the address; in JSON, a " ends the string. Add the transformer
for the place the value lands:
https://www.google.com/search?q={message.content:encode}
{"title": "{modal.title:json}", "by": "{user.name:json}"}
:json does not add the quotes: write them around the token yourself, as
above. For a plain list, one entry per line needs neither (see
your own stored values).
:replace takes \n too: {message.content:replace(\n| )} puts a
multi-line message on one line. The part before the first | is what it
looks for, so the text it looks for cannot itself contain a |.
Deadlines that stay true
A card that says "you have 5 minutes" is wrong a minute later. Discord can render a moment that every reader sees in their own language and timezone, and that keeps counting down on its own:
You will be banned {trigger.timestamp:plus(300):relative}
{trigger.timestamp} is when the rule fired, plus(300) moves it five
minutes forward (plus(5m) says the same: s, m, h and d are read as
seconds, minutes, hours and days), and relative renders it as a live
countdown. The same chain with :datetime writes an absolute date instead.
Automated messages take the same {trigger.timestamp}, the moment they are
sent: {trigger.timestamp:plus(20m):time} is the time twenty minutes later.
A transformer you did not write is left alone
Exactly like an unknown variable: {user.name:shout} stays as typed, and the
editor underlines it, rather than posting the value with the formatting
silently dropped.
Your own stored values
The data store keeps values between runs: they survive restarts, and they are per server.
- Scope:
guild(one value for the server),member(one per member),channel, orgroup(one per workflow group, shared by its rules and invisible to the rest of the server) - Type: text, number, boolean, list or JSON
- Expiry: optional; a strike that fades after 30 days is just a counter with an expiry
Read one into a variable and use it: {vars.<name>.value}, plus
{vars.<name>.exists} and, for lists, {vars.<name>.length}.
The value field takes several lines. A text keeps its line breaks, and a list
is written one entry per line, with no quotes to get right even when the
entry is something a member typed. To grow a list, Add to list appends one
entry at a time. Store data can also rebuild one: a list read with a
Read data step, like {vars.old}, adds all of its entries when it sits on
a line of its own, so
{vars.old}
{modal.suggestion}
stores the old list with the new entry at the end, and swapping the two lines puts it first. A list keeps its first 100 entries.
A suggestion box, step by step
A button opens a form with one field, suggestion; every answer joins a
list, and the whole list is posted in a thread:
- Add to list: variable
suggestions, value{modal.suggestion}. Turn on Skip duplicates to ignore the same answer twice. - Read data: variable
suggestions, published aslist. - Send message to the thread (see posting in a thread):
**Suggestions ({vars.list:count})**
- {vars.list:join(\n- ):escape}
Three things that trip people up
- A variable keeps its type. Add to list on a name that already holds
text fails with
type_mismatch. Delete the variable first, or use a new name. - Full lists behave differently. Add to list refuses a 101st entry with
list_full, so the rule tells you; Store data keeps the first 100 entries and drops the rest without an error. Put the new entry on the first line to keep the newest 100. - A text built from itself starts empty. Storing
{vars.old}then a new line as text writes an empty first line the first time, whileolddoes not exist yet. A list skips empty lines, so prefer the list type.
Counters are atomic: two members clicking at the exact same moment get 4 and 5, never 4 and 4. A missing counter starts at the default you set (usually 0) instead of failing.
An unknown variable is left alone
If you type {user.birthday} and nothing provides it, the text stays exactly
as you typed it. It is never silently replaced by an empty string, so a
typo is visible instead of quietly producing a broken message.
