Skip to content

Commands

from cordless import Cordless
bot = Cordless()
@bot.command("ping", description="Check latency")
async def ping(ctx):
await ctx.send("Pong!")

Command names are validated at decoration time: 1–32 lowercase letters, digits, - or _.

@bot.command("ban", description="Ban a user", default_member_permissions=8)
async def ban(ctx): ...
@bot.command("explicit", description="Adult content", nsfw=True)
async def explicit(ctx): ...

default_member_permissions is a Discord permission bitfield: users without those permissions won’t see the command. nsfw=True restricts the command to age-verified channels.

@bot.command(
"buy",
description="Buy an item",
name_localizations={"es-ES": "comprar", "fr": "acheter"},
description_localizations={"es-ES": "Comprar un artículo"},
)
async def buy(ctx): ...

name_localizations and description_localizations are {locale: value} dicts, keyed by one of Discord’s locale codes. A user whose client is set to a listed locale sees the localised name/description in the command picker; everyone else sees the plain name/description. Subcommands (see below) take their own name_localizations/description_localizations independently of the parent command and of each other.

A Cog groups related handlers into a module. The API mirrors @bot.command, just replace bot with your Cog instance:

cogs/greetings.py
from cordless import Cog, ActionRow, Button
cog = Cog()
@cog.command("greet", description="Say hello")
async def greet(ctx):
await ctx.send(
f"Hello, {ctx.user.username}!",
components=[ActionRow([Button("Wave back", custom_id="wave")])]
)
@cog.button("wave")
async def wave(ctx):
await ctx.edit("👋")
lambda_function.py
bot.load_extensions("cogs") # auto-discovers all Cog instances in the package

Files starting with _ are skipped. Cog mirrors every bot decorator, not just command/button: select, modal, autocomplete, user_command, and message_command all work the same way with cog. instead of bot..

Declare options as typed parameters: cordless infers the Discord option types and passes the values as arguments:

@bot.command("buy", description="Buy an item")
async def buy(ctx, item: str, qty: int = 1):
await ctx.send(f"bought {qty}x {item}")

Parameters without a default are required. Supported annotations: str, int, float, bool, Literal[...] for a fixed set of choices, and Optional[T] / T | None (unwrapped to T; a default value is still what makes the option optional):

from typing import Literal
@bot.command("order", description="Order a drink")
async def order(ctx, size: Literal["small", "medium", "large"], qty: int | None = None):
...

For choices, autocomplete, users, channels, roles, or min/max constraints, use the option() helper:

from cordless import option
@bot.command("greet", description="Greet a user", options=[
option("name", "Who to greet", required=True),
option("times", "How many times", type="integer", min_value=1, max_value=5),
])
async def greet(ctx):
await ctx.send(f"Hey, {ctx.options['name']}!")

Available types: string, integer, number, boolean, user, channel, role, attachment.

For an attachment option, ctx.options[name] holds the attachment’s id, look up the actual Attachment (filename, url, size, content type) on ctx.attachments:

@bot.command("upload", description="Upload a file", options=[
option("file", "The file to upload", type="attachment", required=True),
])
async def upload(ctx):
attachment_id = ctx.options["file"]
meta = ctx.attachments[attachment_id]
await ctx.send(f"Got {meta.filename} ({meta.size} bytes): {meta.url}")

Use parent/sub paths: cordless builds the Discord subcommand tree automatically:

@bot.command("info/bot", description="About this bot")
async def info_bot(ctx): ...
@bot.command("info/server", description="About this server")
async def info_server(ctx): ...

Discord only supports default_member_permissions and nsfw on the top-level command, so cordless combines them across all subcommands of a parent: permission bitfields are unioned, and nsfw=True on any subcommand marks the whole command NSFW. This means one subcommand’s requirements gate its siblings too:

@bot.command("admin/ban", description="Ban a user", default_member_permissions=4) # Ban Members
async def admin_ban(ctx): ...
@bot.command("admin/purge", description="Purge messages", default_member_permissions=8192) # Manage Messages
async def admin_purge(ctx): ...

/admin registers with default_member_permissions=8196: users need both Ban Members and Manage Messages to see either subcommand. If subcommands need different permission levels, register them as separate top-level commands instead.

Mark an option with autocomplete=True, then register a handler with @bot.autocomplete. Return a list of strings: cordless filters them against what the user has typed and caps at Discord’s limit of 25:

@bot.command("shop/buy", description="Buy an item", options=[
option("item", "The item to buy", autocomplete=True),
])
async def shop_buy(ctx):
await ctx.send(f"Bought {ctx.options['item']}")
@bot.autocomplete("shop/buy", "item")
async def item_autocomplete(ctx):
return ["sword", "shield", "potion"]

To show a different label than the value that gets sent, return choice dicts instead: these are sent exactly as returned, with the typed value available on ctx.focused_value for your own filtering:

@bot.autocomplete("shop/buy", "item")
async def item_autocomplete(ctx):
query = (ctx.focused_value or "").lower()
return [{"name": i.title(), "value": i} for i in ITEMS if query in i]

Right-click a user or message → Apps → your command. These take no options and their handler receives the target directly on ctx:

@bot.user_command("Inspect User")
async def inspect_user(ctx):
await ctx.send(f"Target: {ctx.target_user.username}", ephemeral=True)
@bot.message_command("Report Message")
async def report_message(ctx):
await ctx.send(f"Reported: {ctx.target_message.content!r}", ephemeral=True)

ctx.target_user and ctx.target_member (guild-specific member data, e.g. roles/nick) are set for user_command; ctx.target_message is set for message_command. Unlike slash commands, context menu names may contain spaces and capital letters, since they aren’t put in front of users as typed text, so Discord’s slash-command naming rules don’t apply.

Both take name_localizations (see Localisation above); context menu commands have no description, so there is no description_localizations.

A guard runs before the handler and can reject the interaction, useful for permission checks shared across several commands:

from cordless import PermissionDeniedError
def admin_only(ctx):
if not ctx.member or "ADMIN_ROLE_ID" not in ctx.member.roles:
raise PermissionDeniedError("Admins only")
@bot.guard(admin_only)
@bot.command("nuke", description="Dangerous")
async def nuke(ctx):
await ctx.send("💥")

Guards can be sync or async, and run for commands, buttons, selects, and modals alike. Raise anything to stop the handler from running; pair it with @bot.error to turn that into a clean user-facing message instead of a raw 400.

from cordless import CordlessError, PermissionDeniedError
@bot.error
async def on_error(ctx, exc):
if isinstance(exc, PermissionDeniedError):
await ctx.send("You can't do that.", ephemeral=True)
return
print(f"Unhandled error: {exc!r}")
await ctx.send("Something went wrong.", ephemeral=True)

The handler receives (ctx, exc) and can be sync or async. If it sends a response (or returns one), that becomes the interaction’s response. If it does neither, the original exception propagates: CordlessError subclasses (like PermissionDeniedError) become a 400 response automatically; anything else fails the Lambda invocation.

By default Discord pings whoever is mentioned in your reply. Pass allowed_mentions to ctx.send(), ctx.edit(), or ctx.followup() to control this:

# suppress all pings
await ctx.send(f"Hey {user_mention}!", allowed_mentions={"parse": []})
# only ping roles, not users
await ctx.send(content, allowed_mentions={"parse": ["roles"]})

The value is passed directly to Discord. See the Discord docs for all options.

For commands that take more than 3 seconds, set defer=True. Requires defer_worker in cordless.toml.

@bot.command("slow", description="Takes a while", defer=True)
async def slow(ctx):
result = await do_something_slow()
await ctx.send(result)

Add ephemeral=True to make the loading state (and the final reply) visible only to the user who triggered it:

@bot.command("slow", description="Takes a while", defer=True, ephemeral=True)
async def slow(ctx):
result = await do_something_slow()
await ctx.send(result)
@bot.cron("rate(1 hour)")
async def hourly_tick():
await bot.send_message(CHANNEL_ID, "Hourly update!")

Deploy wires these to EventBridge automatically. See Deploying to AWS.

Test locally without deploying:

Terminal window
cordless cron hourly_tick

bot exposes Discord REST methods you can call from any cron handler. All methods require DISCORD_BOT_TOKEN in your environment.

# send a message
await bot.send_message(channel_id, "Hello!")
# send with embeds or components
await bot.send_message(channel_id, embeds=[embed])
# edit or delete
await bot.edit_message(channel_id, message_id, "Updated!")
await bot.delete_message(channel_id, message_id)
# role management
await bot.add_role(guild_id, user_id, role_id)
await bot.remove_role(guild_id, user_id, role_id)
Terminal window
# standalone (auto-detects your Cordless instance)
cordless register
# guild-only (faster propagation, good for development)
cordless register --guild-id YOUR_GUILD_ID
# or as part of deploy
cordless deploy --register

Credentials are read from $DISCORD_BOT_TOKEN, or $DISCORD_CLIENT_ID + $DISCORD_CLIENT_SECRET (from env or .env).

--guild-id overrides every command’s scope for that one call, useful for instant dev iteration. To make some commands global and others guild-only permanently, scope them individually instead:

@bot.command("subscribe") # global, everywhere the bot is installed
async def subscribe(ctx): ...
@bot.command("purge", guild_ids=["829103480018305044"]) # only that one guild
async def purge(ctx): ...
@bot.command("announce", guild_ids=["111111111", "222222222"]) # a specific set of guilds
async def announce(ctx): ...

Plain cordless register (no --guild-id) then pushes each command to its own scope in one pass: global commands to the global endpoint, each guild’s commands to that guild’s, instead of one bulk overwrite. guild_ids= works the same way on Cog.command, user_command, and message_command.