Tflows
A registry-driven scripting framework for discord.py bots — write commands as short text scripts, resolve them against a shared FunctionRegistry, and extend the whole system with your own Python functions and variables.
This video is AI-generated. Always confirm information for yourself beforehand to avoid issues. This video was made possible with Scrimba.
Tflows was built for a simple idea: Discord bots should be easy to write, easy to read, and still flexible enough to grow into full-featured applications. Instead of forcing every command into a separate Python callback, Tflows lets you define behavior in short script lines that are resolved through a shared registry. That makes the command layer lighter, more expressive, and much easier to scale when your bot starts gaining moderation tools, event handlers, stateful flows, or custom automation. The current release, 1.3.0, adds delete and edit message events, targeted channel sends, DMs, and script-visible errors while keeping existing 1.1 and 1.2 scripts compatible.
This is especially useful when many commands share the same pattern: look up a user, send a message, perform a check, then respond with a formatted result. With Tflows, the command becomes a readable script instead of a duplicated async function, while the underlying discord.py runtime remains available whenever you need more direct control.
What is Tflows?
Tflows is a lightweight Python framework built on top of discord.py that lets you define bot behavior using registry-resolved script commands instead of writing a Python event handler for every single command. Commands are written as plain text script lines; each line is a function call, and functions are resolved from a shared FunctionRegistry at execution time.
Because FlowBot extends discord.ext.commands.Bot directly, nothing about normal discord.py development goes away — cogs, listeners, app commands, and regular @bot.command() functions all continue to work side by side with script commands. Tflows is meant to sit on top of discord.py for automation and rapid iteration, not to replace it.
The real power comes from the separation between script intent and Python implementation. Your bot can react to a trigger using a short script, but the actual work can still be performed by registered Python functions, variables, and contextual helpers. That keeps the bot logic readable at a glance without sacrificing the full power of the discord.py ecosystem.
🐢 Traditional discord.py
- Per-command async function callbacks
- Manual ctx.send() and Embed construction
- Verbose context lookups for author/guild/channel
- Restart usually required to change command text
⚡ Tflows Scripting
- Commands registered via
bot.command(name, code, ...) - Functions and variables resolved from a shared registry
$variableinterpolation resolved by the Engine- Custom functions still get full access to discord.py via FlowContext
Key Features
Script Commands
Register commands with bot.command(name, code, description="", aliases=()).
Registry-Driven Engine
Every function and variable call is resolved from a FunctionRegistry at execution time.
Extensible
registry.register() and registry.register_var() let you add your own functions and variables.
Aliasing
register_alias and register_var_alias give existing entries friendlier names.
Isolated Registries
Pass registry=FunctionRegistry() to keep a bot's function set separate from the defaults.
Fully Compatible
FlowBot is a commands.Bot subclass — cogs, listeners, and app commands all still work.
Who is it for?
Tflows is for developers who want to ship straightforward commands quickly without writing an async function for each one, while keeping full discord.py available for anything more involved. It's a good fit for bots with a lot of small, similar commands — greetings, status messages, moderation shortcuts — where a script line is faster to write and easier to review than a full command function.
Tflows wraps discord.py and requires Python 3.10+ together with a valid Discord bot token.
Installation #
Install Tflows from PyPI using pip. discord.py is pulled in automatically as a dependency.
This video is AI-generated. Always confirm information for yourself beforehand to avoid issues. This video was made possible with Scrimba.
$ pip install tflows
Requires Python 3.10 or newer. Example bots live in the examples/ folder of the repository (basic_bot.py, variables.py, embeds.py, automation.py, mixing.py, advanced.py).
Verify the installation
import tflows
print(tflows.__version__)
Links
tflows.py is released under the Apache License 2.0.
Quick Start #
Below is a minimal working bot. It registers a single !test command that replies with a message showing current latency.
from tflows import FlowBot
bot = FlowBot(prefix="!")
bot.command(
"test",
"""
send Hello World $ping
"""
)
bot.run("YOUR_BOT_TOKEN")
Trigger it in Discord with !test. The bot sends a message such as Hello World 42.00ms (latency will vary).
Never hard-code your bot token in source files. Use environment variables or a .env file with python-dotenv.
A richer example with an embed
embed
$title[Server Status]
$desc[Latency: $ping
Server: $server
Members: $membercount
Time: $time(24h)]
endembed
Architecture #
Tflows is organized around four pieces that work together on every command invocation: FlowBot, the Engine, FlowContext, and the FunctionRegistry. Understanding how they relate makes it much easier to reason about custom functions, isolated registries, and error handling.
Components
FlowBot
The main bot class. Extends discord.ext.commands.Bot, adds .command(name, code, ...) for script registration, and owns the default FunctionRegistry.
Engine
The script interpreter. Executes a command's script text line by line against a FlowContext, resolving variables and dispatching function calls.
FlowContext
The execution context passed to every function and variable. Carries the real discord.py objects for this invocation — message, author, guild, channel, args.
FunctionRegistry
A lookup table of function and variable names to Python callables. Pre-loaded with built-ins by default, or supplied fresh for isolation.
tflows.builtins
Sets up the base registry with the standard library of functions and variables before any bot-specific code runs.
tflows.function
Additional function modules, discovered and loaded automatically, alphabetically by file name, at bot startup.
How a command flows through these pieces
Message received
A message arrives that matches the configured prefix and a registered command name.
Script lookup
FlowBot retrieves the stored script text for that command name (or alias).
Context built
A FlowContext is constructed for this invocation, wrapping the message, author, guild, channel, and parsed arguments.
Engine executes the script
The Engine walks the script line by line, resolving $variables and calling functions from the FunctionRegistry as it goes.
Output reaches Discord
Function calls like send, react, or an embed block use the real discord.py objects in the FlowContext to produce output.
Because FlowBot is a genuine commands.Bot subclass, none of this replaces discord.py's gateway handling — it only adds a script execution layer on top of a normal command invocation.
FlowBot #
FlowBot is the main entry point. It subclasses discord.ext.commands.Bot, so every normal discord.py feature — commands, cogs, listeners, checks, app commands — keeps working exactly as it would in a plain discord.py bot. On top of that, it adds script command registration and manages the shared FunctionRegistry.
from tflows import FlowBot
bot = FlowBot(prefix="!")
bot.command(
"greet",
"""
send Hi $id(user)!
""",
description="Greets the invoking user.",
aliases=("hi",)
)
bot.run("TOKEN")
Constructor
| Parameter | Type | Description |
|---|---|---|
| prefix | str | list | The command prefix, e.g. "!" — or a list of prefixes. |
| help_command | bool | Set to False to disable the built-in help command. See Help System. |
| log_errors | bool | Catch and log exceptions raised during script execution instead of letting them propagate. |
| log_unknown_functions | bool | Configuration flag retained for compatibility. Unknown functions are skipped and logged at INFO level by the current engine. |
| case_insensitive | bool | Match command names case-insensitively, passed through to the underlying commands.Bot behavior. |
| members_intent | bool | Enables the guild members intent, required for accurate member-based variables like membercount breakdowns. |
| registry | FunctionRegistry | Optional. Supply a pre-built registry for isolated function sets. See Isolated Registries. |
| state_path | str | None | SQLite state location. Defaults to tflows.db; use ":memory:" for tests or None to disable persistence. |
| allow_http | bool | Opt in to script HTTP requests. Disabled by default; TFLOWS_ALLOW_HTTP=1 is also supported. |
See FlowBot Options for a deeper look at how each of these affects runtime behavior.
Script Commands #
Script commands are registered with bot.command():
bot.command(
name,
code,
description="",
aliases=()
)
| Parameter | Type | Description |
|---|---|---|
| name | str | The command's trigger name, invoked as {prefix}{name}. |
| code | str | The script text — usually a triple-quoted multi-line string. |
| description | str | Optional. Shown in the help command output. |
| aliases | tuple | Optional. Alternate names that trigger the same script. |
bot.command(
"greet",
"""
send Hello, $user!
""",
description="Greets the user who ran the command.",
aliases=("hi", "hello")
)
Because FlowBot extends commands.Bot, script commands live in the same namespace as regular discord.py commands — you can freely mix the two on the same bot. See Mixing with discord.py for patterns.
Execution Model #
The Engine treats a script as a sequence of lines executed top to bottom. Each non-empty, non-comment line is a function call; the first token is the function name and the rest of the line is passed as arguments.
send Hello there!
react 👍
wait 2
Variable resolution
Variables are written as $name or $name(arg) and are resolved before the line's function is called. Built-in examples include $user, $server, $args, $ping, and $command.
send Welcome to $server, $user!
send Your command arguments were: $args
If a variable name isn't recognized, it's left in the output unchanged rather than raising an error. This makes typos and unregistered variables obvious in testing without crashing the command.
Comments
Three comment styles are supported. Any of them can annotate a script without affecting execution:
// this is a comment
# this is also a comment
-- this is also a comment
Unknown functions
If a line calls a function name that isn't in the registry, the Engine does not crash the whole bot. It skips the call, writes an INFO-level log entry, and continues with the rest of the script. A single typo in one command won't take down every other command running on the bot.
Errors during execution
Errors raised inside a function during execution can be caught and logged rather than propagated as an unhandled exception, depending on the log_errors option — useful in production, where you'd rather see an error in your logs than have the bot silently stop responding to that command.
Embed Blocks #
A script can build a rich Discord embed using an embed / endembed block. Inside the block, recognized fields are set line by line and sent as a single discord.Embed when the block closes.
embed
$title[Server Stats]
$desc[Here's what's happening in $server right now.]
$color[#5865F2]
$thumbnail[$avatar]
$footer[Requested by $user]
endembed
Supported fields
| Field | Description |
|---|---|
| $title[...] | Embed title. Supports variable interpolation. |
| $desc[...] | Embed description. Put multiple lines inside one bracketed value. |
| $footer[...] | Footer text, shown at the bottom of the embed. |
| $color[...] | Accent color, given as a hex value or supported color name. |
| $thumbnail[...] | Small image shown in the top-right of the embed. Accepts an image URL or a variable like $avatar. |
| $image[...] | Large image shown at the bottom of the embed body. |
| $author[...] | Author line shown above the title. |
Variables are resolved inside embed fields the same way they are on any other line, so $user, $server, and custom variables all work inside title, desc, and the other fields.
An embed block must be closed with endembed. Without it, the Engine won't know where the embed's field lines end and the rest of the script begins.
Custom Functions #
Register a new function into the registry with registry.register():
async def say_loud(ctx, args):
await ctx.channel.send(args.upper() + "!!!")
registry.register("shout", say_loud)
Once registered, shout can be called from any script the same way a built-in function is called:
shout this is important
Custom functions receive the current FlowContext and one raw argument string: fn(ctx, args). Use the context's real discord.py objects — ctx.channel, ctx.author, ctx.guild, and so on — to perform actions. A function return value is ignored, so send any user-visible result yourself.
async def timeout_user(ctx, args):
member_id, minutes = args.split()
member = ctx.guild.get_member(int(member_id))
# perform a real discord.py action using ctx.guild, ctx.channel, etc.
await ctx.channel.send(f"Timed out {member.display_name} for {minutes} minutes.")
registry.register("timeout", timeout_user)
This is the intended way to use Tflows for anything beyond simple text or embed output: write a script line as the trigger, and let a registered Python function do the real work using full discord.py access.
Custom Variables #
Register a new variable with registry.register_var():
def nickname(ctx, args):
return ctx.author.display_name
registry.register_var("nick", nickname)
This makes $nick available inside any script:
send Welcome back, $nick!
Custom variables work exactly like built-in variables once registered — they can appear standalone, inside embed fields, or as arguments to other function calls.
Aliases #
Give an existing function or variable an additional name without duplicating its implementation, using register_alias and register_var_alias:
registry.register_alias("yell", "shout")
registry.register_var_alias("name", "nick")
After this, yell behaves identically to shout, and $name resolves the same way as $nick. Aliases are useful when you want a friendlier or shorter name for users writing scripts, without maintaining two separate implementations.
Isolated Registries #
By default, FlowBot uses a process-wide shared FunctionRegistry. Pass your own registry instance when you need custom registrations isolated from other bots in the same process:
from tflows import FlowBot, FunctionRegistry
custom_registry = FunctionRegistry()
custom_registry.register("shout", say_loud)
bot = FlowBot(
prefix="!",
registry=custom_registry
)
FlowBot loads its standard built-ins into the registry it receives, including a fresh FunctionRegistry(). Isolation separates your custom registrations from the shared registry; it does not disable built-ins.
Isolation is useful when separate bots need independent custom functions and variables, or when embedding Tflows scripting into a larger application.
Built-in Modules #
FlowBot automatically loads a default set of functions and variables at startup from two places:
tflows.builtins
Sets up the base registry — this is where the standard library of functions and variables gets registered before any bot-specific code runs.
tflows.function
Additional function modules are discovered and loaded dynamically during startup, in alphabetical order by file name. If a module fails to import or initialize, the failure is logged and loading continues with the rest.
This means the built-in function set can grow over time without changing how FlowBot is constructed — new modules under tflows.function are picked up automatically.
Module summary
| Module | Purpose |
|---|---|
| send | Send a plain text message to the current channel. |
| reply | Reply directly to the message that invoked the command. |
| log | Print debug output while developing a script. |
| wait | Pause script execution for a given duration before continuing. |
| react | Add one or more reactions to the invoking message. |
| delete | Delete a specific message. |
| clear | Purge a number of messages from the current channel. |
| ping | Report the bot's current latency. |
| time | Return the current time, with optional formatting. |
| uptime | Return how long the bot process has been running. |
| user | Access information about the command's invoking author. |
| guild | Access information about the current server. |
| channel | Access information about the current channel. |
| membercount | Return member counts for the guild, optionally filtered. |
| image / avatar | Return a profile image or avatar URL. |
| id | Return a Discord snowflake ID for the author, a mentioned user, or another target. |
| random | Generate random values for use in scripts. |
| botinfo | Return information about the bot itself. |
| embed | The embed / endembed block described in Embed Blocks. |
Other modules may exist under tflows.function beyond this list — since modules are loaded automatically, check your installed package for the current full set if you need something not listed here.
FlowBot Options #
These options are typically all you need to configure for most bots — the defaults are reasonable for development, and log_errors / log_unknown_functions are worth turning on deliberately once you move toward production.
| Option | Effect |
|---|---|
| prefix | The command prefix string(s) that trigger command lookup, same as in discord.py. |
| help_command | Set to False to disable the built-in help entirely. |
| log_errors | Catches and logs exceptions raised during script execution instead of propagating them as unhandled exceptions. |
| log_unknown_functions | Configuration flag retained for compatibility. The current engine logs unknown functions at INFO level in all cases. |
| case_insensitive | Controls whether command names are matched case-insensitively. |
| members_intent | Controls whether the guild members intent is requested, required for functions/variables needing full member lists. |
| registry | Optionally pass a pre-built FunctionRegistry. See Isolated Registries. |
| state_path | SQLite state location; defaults to tflows.db. Use ":memory:" for tests or None to disable persistence. |
| allow_http | Opt in to HTTP functions. Disabled by default; the TFLOWS_ALLOW_HTTP=1 environment variable is also supported. |
| http_allowlist | Optional exact hostname allowlist for outbound HTTP requests. |
| intents | Optional discord.Intents to use instead of the defaults (which already enable message_content). |
Help System #
FlowBot's built-in help command lists registered script commands. For each script command, the help output can show:
- The command name and any registered aliases
- The description passed to
bot.command() - Basic usage information
- The command's description, aliases, and usage without exposing the underlying script source
If you want full control over how help is presented, or you're integrating Tflows into a bot that already has its own help system, pass help_command=False when constructing FlowBot.
bot = FlowBot(
prefix="!",
help_command=False # disable built-in help
)
Variables #
Variables are special tokens embedded in your scripts, prefixed with $. They are resolved at runtime against the live FlowContext and replaced with their current values before output is generated.
Variables come in three flavors: static (no arguments, always produce the same type of value), function (accept an optional mode flag in parentheses), and dynamic (multiple formatting modes, such as time-based output).
$ping
staticReturns the bot's current WebSocket latency as a string with two decimal places and an ms suffix.
| Usage | Returns |
|---|---|
| $ping | Latency, e.g. 42.00ms |
send Pong! $ping
$user
functionReturns information about the command's invoking author. Defaults to the author's username; use $user(display) for the display name.
| Mode | Description |
|---|---|
| $user default | Author's username |
| $user(name) | Author's raw username |
| $user(mention) | Author mention string |
send Welcome, $user!
$server
functionReturns information about the current Discord guild (server). Defaults to returning the server name.
| Mode | Description |
|---|---|
| $server default | Guild name |
| $server(name) | Explicitly returns the guild name |
| $server(boost) | Current number of Nitro boosts |
| $server(boostlvl) | Current boost tier level (0, 1, 2, or 3) |
$server
$server(name)
$server(boost)
$server(boostlvl)
$args
staticReturns the raw text the user typed after the command name — everything following {prefix}{name}, unparsed.
| Usage | Returns |
|---|---|
| $args | Raw argument string, e.g. spam bot 15 |
warn $args
$command
staticReturns the name of the command currently being executed — the registered name, not the alias the user typed, if any.
| Usage | Returns |
|---|---|
| $command | Command name, e.g. greet |
$uptime
dynamicReturns how long the bot has been running since startup. Supports multiple formatting modes including full, short, clock, seconds, and custom token-based output.
| Usage | Returns |
|---|---|
| $uptime | Default format, e.g. 1h 23m 10s |
| $uptime(full) | Full format including days, e.g. 0d 1h 23m 10s |
| $uptime(short) | Compact format, e.g. 1h 23m |
| $uptime(clock) | Clock-style format, e.g. 01:23:10 |
| $uptime(seconds) | Raw uptime in seconds, e.g. 4990 |
| $uptime(d, h, m, s) | Custom formatted output using tokens |
embed
$title[Uptime Variants]
$desc[Default: $uptime
Full: $uptime(full)
Short: $uptime(short)
Clock: $uptime(clock)
Seconds: $uptime(seconds)
Custom: $uptime(d, h, m, s)]
endembed
log
functionWrites a message to the bot process's console. Use it to inspect values while developing a script.
| Usage | Effect |
|---|---|
| log MESSAGE | Prints MESSAGE to the console after variable interpolation. |
log User executed a command
Behavior
- Writes to standard output with a
[tflow log]prefix - Does not send a Discord message
- Supports variables like
$user,$time, and other engine variables
Examples
log User executed !ping command
log $user used a command at $time
log $user was warned by $id for spam
Notes
- Console output is intended for development and process logs
- Use
sendorreplywhen a user should see the message in Discord
$image
functionReturns a Discord CDN avatar image URL. $image and $avatar provide the same modes through separate built-in registrations.
| Mode | Description |
|---|---|
| $image default | Command author's avatar URL |
| $image(user) | Same as default — author's avatar |
| $image(mention) | First mentioned user's avatar. Falls back to author if no mention. |
| $image(act) | Mentioned user's avatar if a mention is present, otherwise author's. |
$image
$image(user)
$image(mention)
$image(act)
$membercount
functionReturns the guild member count. Can be filtered to include all members, only humans, or only bots. Requires members_intent=True for accurate human/bot breakdowns.
| Mode | Description |
|---|---|
| $membercount default | Total member count (humans + bots) |
| $membercount(all) | Same as default |
| $membercount(user) | Count of non-bot members only |
| $membercount(bots) | Count of bot accounts only |
$membercount
$membercount(all)
$membercount(user)
$membercount(bots)
$time
functionOutputs the current date and/or time. Accepts flags to control format; multiple flags can be combined with a semicolon delimiter.
| Mode | Description |
|---|---|
| $time() default | Full date + time in the default format |
| $time(12h) | 12-hour clock format (AM/PM) |
| $time(24h) | 24-hour clock format |
| $time(nodate) | Time only — date portion omitted |
| $time(notime) | Date only — time portion omitted |
| $time(nodate;24h) | Combine flags with ; for composed output |
$time()
$time(12h)
$time(24h)
$time(nodate)
$time(notime)
$time(nodate;24h)
$id
functionReturns a Discord user ID as a string. Accepts an optional mode to select which user's ID to return.
| Mode | Description |
|---|---|
| $id default | Command author's Discord user ID |
| $id(user) | Same as default |
| $id(mention) | First mentioned user's ID. Falls back to author if no mention. |
| $id(act) | Mentioned user's ID if present, otherwise author's ID. |
$id
$id(user)
$id(mention)
$id(act)
$argcount
staticReturns how many arguments the user passed — useful for validating input before using $arg(n).
| Usage | Returns |
|---|---|
| $argcount | Number of arguments, e.g. 2 |
reply Got $argcount args
$prefix
staticReturns the bot's command prefix — handy in help text so examples stay correct if the prefix ever changes.
| Usage | Returns |
|---|---|
| $prefix | Command prefix, e.g. ! |
$random
functionReturns a random integer in the given inclusive range.
| Usage | Returns |
|---|---|
| $random(a, b) | Random number between a and b, e.g. $random(1, 6) |
Video Guide #
This video is AI-generated. Always confirm information for yourself beforehand to avoid issues. This video was made possible with Scrimba.
Mixing with discord.py #
Because FlowBot is a subclass of commands.Bot, script commands and regular discord.py commands, cogs, listeners, and app commands all coexist on the same bot without any special handling.
Normal command alongside a script command
@bot.command()
async def python_ping(ctx):
await ctx.send(f"Pong! {round(bot.latency * 1000)}ms")
bot.command(
"ping",
"send Pong! $ping ms"
)
Listeners
Listeners work exactly as in discord.py:
@bot.event
async def on_member_join(member):
channel = member.guild.system_channel
if channel:
await channel.send(f"Welcome, {member.mention}!")
Cogs
Cogs work exactly as in discord.py, and can even register script commands during setup by calling bot.command() from within the cog's __init__, since bot.command() is just a method on the FlowBot instance:
class Moderation(commands.Cog):
def __init__(self, bot):
self.bot = bot
bot.command(
"rules",
"send Please review the server rules in #rules."
)
@commands.command()
async def warn(self, ctx, member: discord.Member, *, reason):
await ctx.send(f"{member.mention} was warned: {reason}")
bot.add_cog(Moderation(bot))
Custom functions calling into discord.py
Custom functions registered into the registry can call straight into discord.py from inside the FlowContext, so a script command can trigger arbitrarily complex Python logic while still being invoked as a simple script line — see Custom Functions for the full pattern.
Conditionals #
Scripts can branch with if / elif / else blocks closed by endif (or end if). Conditions support ==, !=, >, <, >=, <=, contains, startswith, endswith, in, and and / or / not — and blocks nest. Conditions are quote-aware: operators and comparison-like text inside quoted strings are left alone, so "fish and chips" == x does not split on the inner and.
if $argcount == 0
reply Usage: !greet <name>
elif $arg(0) contains admin
reply Hello, boss.
else
reply Hello $arg(0)!
endif
Slash Commands #
Any script can also run as a Discord slash command. Pass slash=True with a slash_params list, or use bot.slashcommand(...) — declared params map to $args / $arg(n) / $arg(name) inside the script, using the same 0-based argument system as prefix commands (the first argument is $arg(0), with $argcount for the total). Sync them to Discord with await bot.sync_commands().
bot.command("greet", "reply Hello $arg(name)!", slash=True, slash_params=["name"])
await bot.sync_commands()
Parameters can declare types, including optional parameters — an unrecognized type string is rejected at registration instead of silently becoming text. Note that slash_params on its own does nothing: without slash=True no slash command is created and the configuration produces a warning.
Cooldowns & Guards #
Rate-limit spammy commands and gatekeep sensitive ones without leaving script-land. Cooldown scopes are per user, per channel, per guild, or global; expired per-user entries are pruned automatically. Durations support mixed units such as 1h 30m, 500ms, and word forms such as 2 minutes. Top-level cooldowns and guards run before the rest of the script, even when they are not the first lines. Invalid cooldown durations and malformed require lines fail closed and block execution. The $hasrole(), $hasperm(), and $isowner variables let scripts branch on the same checks.
cooldown 5s per user
require manage_messages
clear 10
require role Mod
require owner
reply This only runs for mods and the owner.
Persistent State #
Scripts are no longer stateless. A SQLite-backed store (a lazy tflows.db file by default, state_path=":memory:" or None for ephemeral use) persists per-server data across restarts. Use the set / get / del / incr functions, or read values inline with $get(key, fallback). Version 1.2.0 also adds exists / $exists(key) and keys / $keys() for checking and listing stored values. Keys can address a specific user, e.g. points[$user]:
incr greet_count
reply You have been greeted $get(greet_count, 0) times!
set points[$user] 10
incr points[$user]
get points[$user]
Scheduled Tasks #
Run scripts on a timer instead of waiting for messages. Register tasks with bot.schedule(name, code, interval=...) using mixed duration strings or 5-field cron, or mark a script with an every 1h: header. Cron follows standard day-of-month/day-of-week semantics (weekday 7 is accepted as Sunday), comments are tolerated around schedule headers, and an invalid cron expression produces a proper error instead of silently misfiring. Version 1.2.0 adds retry and backoff controls for scheduled task failures. Re-scheduling a name replaces the old task (no duplicates), overlapping runs are skipped, tasks start and stop cleanly, and run_once fires a scheduled script on demand.
bot.schedule("standup", "send Good morning! $uptime(short)", interval="1h")
Event Triggers #
React to things that aren't commands. Attach scripts to Discord events with bot.on_event("join" | "leave" | "react" | ..., code) — the mapping lives in the extensible tflows.events.EVENT_MAP — and detach them with bot.remove_event(). The same triggers work in script form:
bot.on_event("join", "send Welcome, $user(display)!")
on join:
send Welcome, $user(display)!
on react:
reply Noted!
on edit:
send $user edited a message: $before -> $after
on delete:
send $user deleted: $before
Since 1.3.0, scripts can also react to message edits and deletions with on edit / on message_edit and on delete / on message_delete. Bot-authored messages are ignored, and the existing where filters apply — match numeric ids with user_id / $user_id instead of the display name.
Locals & Expressions #
Use let to keep per-command data without switching back to Python. Values are interpolated with $name, can be reassigned with plain assignment, and can be combined with arithmetic or comparisons.
let name = $arg(0)
let score = 2 + 3 * 4
if score >= 10:
reply Hello $name, you scored $score
endif
A lone $name keeps the original object value (including lists, JSON, and HTTP results), which is handy when you want to pass structured data into later expressions.
Loops #
Loop through groups of values or repeat a block a fixed number of times without writing custom Python. Both for ... in ... and repeat N support break and continue.
for item in red, blue, green:
send $item
endfor
repeat 3:
send Round $i
endrepeat
The loop index is available as $i or $index for repeat blocks, and $members, $roles, and $channels can be iterated directly in scripts.
1.2.0 Updates #
Released September 10, 2026, Tflows 1.2.0 keeps existing 1.1 syntax working and adds more control over timing, branching, state, and runtime behavior. Single-unit durations such as 5s, 2m, and 1h remain unchanged.
Mixed durations and delayed blocks
Durations now accept combinations of units — ms, s, m, h, d, and w — including word forms such as seconds, minutes, and hours. Use after to schedule a script block later; a zero duration runs it inline.
after 1h 30m:
send Reminder: check the queue
cooldown 500ms per user
wait 2 minutes
Branching and developer configuration
switch, case, and default provide readable multi-way branching. Use choose or $choose(...) for random picks, config or $config(key) for developer-defined values, and $duration(...) to format durations.
switch $arg(0):
case ping:
reply pong
default:
reply Try ping
reply $choose(red, blue, green)
FlowBot runtime knobs
FlowBot accepts limits and behavior controls for waits, loops, clearing messages, components, HTTP, and scheduled retries: max_wait, max_repeat, max_clear, component_timeout, http_timeout, http_max_timeout, http_max_body, schedule_retries, schedule_backoff, and developer config={...}.
bot = FlowBot(
prefix="!",
max_wait=120,
max_repeat=1000,
schedule_retries=3,
schedule_backoff=5,
config={"brand": "Tflows"},
)
1.3.0 Updates #
Released September 20, 2026, Tflows 1.3.0 adds delete and edit message events, targeted sends, DMs, and script-visible errors. Existing 1.1 and 1.2 scripts stay compatible.
Delete and edit events
React to message deletions and edits with on delete / on message_delete (on_message_delete) and on edit / on message_edit (on_message_edit). Bot-authored messages are ignored. $message, $content, $before, and $after are set for both: on edit they hold the old and new content, on delete $before is the deleted content and $after is empty.
on delete:
sendto 123456789 $user deleted: $before
on edit where user == Tester:
send Edit spotted: $before -> $after
Targeted sends and DMs
Reach beyond the current channel: sendto <channel_id> <text> sends to a channel by id, embedto <channel_id> ... endembed routes an embed block to another channel, and $dm <user-or-users> <message> DMs one user, a user id, a variable, or a collection such as $users. Invalid ids and API errors are logged without crashing, and one failed DM recipient does not block the rest.
sendto 123456789 Hello from another channel
embedto 123456789
title Weekly summary
send Reports are ready
endembed
send Welcome DM sent: $dm($user, "Thanks for joining!")
Script-visible errors
$errormsg holds the complete human-readable error from the current script run — invalid function, argument, channel, and more. It is cleared at the start of each run, so on error handlers can report it directly, for example sendto ... ERROR: $errormsg. Combined with delete/edit events and where filters, full message logging can now be implemented entirely in .tflow scripts (see examples/message_logger.tflow in the repository).
Filtering by id
where user matches the user name, as in 1.1 and 1.2. To match numeric ids in event filters, use user_id or $user_id, for example on delete where $user_id == 123....
Script Functions #
Write reusable script helpers with function name(data):. Functions can return a value, be invoked as a statement, or be interpolated inline with $name(arg).
function greet(name):
return Hello $name
endfunction
send $greet(Ada)
greet Ada
Function bodies get isolated locals, recursion is capped, and parameters default to an empty string when omitted.
Components #
Tflows adds script-native interactive UI building blocks. Buttons, selects, modals, and deferred interaction responses work directly in the same script language.
button "Click me" id="hello" style="primary":
reply You clicked!
select id="color" placeholder="Pick a color":
option "Red" value="red"
option "Blue" value="blue"
send You picked $value
modal "Feedback" id="fb":
input "message" placeholder="Your feedback"
send Thanks: $input.message
Persistent handlers are keyed by custom ID, and helper calls like defer, ephemeral, and show_modal integrate cleanly with Discord interaction flows.
HTTP & JSON #
HTTP access is intentionally opt-in. Enable it with FlowBot(allow_http=True) or the TFLOWS_ALLOW_HTTP=1 environment variable, and then use http.get, http.post, and JSON helpers directly in scripts. Requests run off the event loop and fail closed when HTTP is not enabled.
let response = http.get "https://api.example.com/data"
let data = json.parse $response.body
send $data[name]
Security defaults are strict: HTTPS only unless allow_insecure_http=True, private/loopback/link-local and cloud-metadata addresses are blocked after DNS resolution, redirects are not followed, requests have a 10-second timeout and 1 MiB body cap, and an exact hostname allowlist can be configured with http_allowlist.
Management #
Beyond basic messaging, scripts can manage roles, members, and channels directly with built-in commands such as role add, kick, ban, timeout, and channel rename. Moderation and channel actions require the matching permission on both the invoker and the bot; kick, ban, and timeout require an explicit target.
role add Moderator
kick $user reason="spam"
timeout $user 10m
These actions are designed to fail safely, and they integrate with the same script guards and permission checks as any other command.
State Scopes #
Persistent state can now be organized by namespace. Use prefixes such as user., channel., guild., or global. to isolate data by context instead of sharing everything in one flat bucket.
set user.points[$user] 10
set guild.flag 1
set global.flag 1
get user.points[$user] 0
Unprefixed keys remain per-guild for backward compatibility, while explicit scopes give you better control over cross-server or per-channel state.
Tooling #
Version 1.2.0 includes the operational tools for script development and maintenance — bot.load(), bot.reload(), bot.watch(), tflows check, and context-menu commands — together with flexible durations, configurable runtime limits, scheduled retries, and security hardening.
bot.load("bot.flow")
await bot.reload()
await bot.sync_commands()
tflows check validates scripts for syntax and structural issues such as unclosed blocks, invalid imports, and unknown function names, while context menus let you attach user/message actions without writing a full listener. Reloads preserve the previous version when a new file has syntax errors, and SQLite state uses WAL mode with a busy timeout for safer concurrent access.
Best Practices #
Following these patterns will make your Tflows scripts more readable, maintainable, and robust.
Keep scripts short and single-purpose
If a script is doing several unrelated things, it's often a sign that logic should move into a custom function instead.
Prefer reusable custom functions over duplication
If two commands both need the same lookup or action, register it once as a custom function rather than copy-pasting script lines.
Use aliases for friendlier command and variable names
register_alias and register_var_alias let you expose short, user-friendly names without renaming the underlying implementation.
Validate arguments before acting
Especially for destructive functions like delete or clear — validate inside the custom function before performing the action.
Store larger scripts in separate files
For bots with many commands, keep script text in .txt or .flow files and load them with open().read() to keep your main Python file clean.
Separate reusable logic from one-off scripts
Reusable custom functions belong in their own module that can be imported and registered into multiple bots' registries — don't bury them inside a single bot's setup file.
Debugging & Errors #
A few habits make debugging scripts and handling errors much less painful:
Enable log_errors and log_unknown_functions in development
They turn silent failures into visible ones, which is almost always what you want while writing new scripts.
Use the log function to print intermediate values
Print values from inside a script while you're figuring out why a command isn't behaving as expected.
Catch expected failures inside custom functions
A bad ID, a missing permission, a network hiccup — catch the specific exception and return a clear, user-facing message rather than letting a raw traceback leak into script output.
Test new variables in isolation
Try a throwaway command like send Value: $newvar before wiring a new variable into a more complex embed or multi-line script.
Tune logging for production
Once a bot is live, consider routing log_unknown_functions output to a private log channel instead of the console, so a stream of user typos doesn't drown out real errors.
Common Mistakes #
These are the most frequent errors new Tflows users encounter, and how to fix them.
Forgetting to close an embed block
An embed block that is never closed with endembed leaves the Engine unable to tell where the embed's fields end and the rest of the script begins.
# ❌ missing endembed
embed
$title[My Title]
$desc[My description]
# ✅ block properly closed
embed
$title[My Title]
$desc[My description]
endembed
Registering a variable through registry.register() instead of register_var()
Functions and variables are tracked separately in the FunctionRegistry. A callable registered with register() is only reachable as a function call, not as a $variable.
# ❌ won't be usable as $nick
registry.register("nick", nickname)
# ✅ usable as $nick
registry.register_var("nick", nickname)
Assuming an isolated registry excludes the built-ins
A fresh FunctionRegistry() starts empty, but FlowBot loads its built-ins into whichever registry it receives during construction. Pass a registry to isolate custom registrations, not to create a built-in-free bot.
If scripts report every function as unknown, verify that they are being executed by a FlowBot instance and that the registry has not been replaced after construction.
Incorrect mode flag names
Mode flags are normalized to lowercase. Use the documented identifiers; unsupported modes fall back to the variable's default behavior.
$membercount(users) # ❌ should be "user"
$image(Mention) # ❌ should be lowercase "mention"
Using mention without an actual mention
$id(mention) and $image(mention) fall back to the author if no mention is present — but the fallback exists to prevent crashes, not to produce the expected output. If you need flexible mention handling, use act mode instead and document the expected usage.
Examples #
Server info command
bot.command(
"server",
"""
embed
$title[$server Info]
$desc[Members: $membercount
Humans: $membercount(user)
Bots: $membercount(bots)
Boost Level: $server(boostlvl)
Boosts: $server(boost)]
endembed
""",
description="Shows server statistics."
)
Custom greeting with a custom variable
def nickname(ctx, args):
return ctx.author.display_name
registry.register_var("nick", nickname)
bot.command(
"welcome",
"""
embed
$title[Welcome!]
$desc[Hey $nick, glad you're here in $server.]
$footer[Member #$membercount(all)]
endembed
""",
description="Greets the invoking user."
)
Moderation function wired to a script command
async def warn_user(ctx, args):
member_id, *reason_words = args.split()
reason = " ".join(reason_words) or "No reason given."
await ctx.channel.send(f"<@{member_id}> has been warned: {reason}")
registry.register("warn", warn_user)
bot.command(
"warn",
"warn $args",
description="Warn a user by ID with a reason.",
aliases=("w",)
)
Uptime status command
bot.command(
"status",
"""
embed
$title[Bot Status]
$desc[Latency: $ping
Uptime: $uptime(short)
Time: $time(nodate;24h)]
endembed
"""
)
Combining a script command with a discord.py cog
import discord
from discord.ext import commands
from tflows import FlowBot, FunctionRegistry
registry = FunctionRegistry()
def nickname(ctx, args):
return ctx.author.display_name
registry.register_var("nick", nickname)
registry.register_var_alias("name", "nick")
async def warn_user(ctx, args):
member_id, *reason_words = args.split()
reason = " ".join(reason_words) or "No reason given."
await ctx.channel.send(f"<@{member_id}> has been warned: {reason}")
registry.register("warn", warn_user)
bot = FlowBot(
prefix="!",
log_errors=True,
log_unknown_functions=True,
case_insensitive=True,
members_intent=True,
registry=registry
)
bot.command(
"welcome",
"""
// greets the user with their display name
embed
$title[Welcome!]
$desc[Hey $nick, glad you're here in $server.]
$footer[Member #$membercount(all)]
endembed
""",
description="Greets the invoking user."
)
bot.command(
"warn",
"warn $args",
description="Warn a user by ID with a reason.",
aliases=("w",)
)
class Moderation(commands.Cog):
def __init__(self, bot):
self.bot = bot
@commands.command()
async def kick(self, ctx, member: discord.Member, *, reason=None):
await member.kick(reason=reason)
await ctx.send(f"Kicked {member.display_name}.")
bot.add_cog(Moderation(bot))
@bot.event
async def on_member_join(member):
channel = member.guild.system_channel
if channel:
await channel.send(f"Welcome to the server, {member.mention}!")
bot.run("YOUR_BOT_TOKEN")
This bot uses an isolated registry with a custom variable ($nick / $name) and a custom moderation function (warn), registers two script commands, and still has a fully normal discord.py cog and event listener running alongside them — demonstrating how Tflows scripting and standard discord.py code are meant to be used together rather than as alternatives to each other.
Why Tflows?
T represents Tonie, the creator. flows represents the flow of behavior in Tflows: a trigger leads to processing, actions, and ultimately a result. The deeper idea is that Tflows turns intent into executable flows of behavior. The .py represents its Python foundation.