Commands
Defining commands
Section titled “Defining 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 _.
Permissions and NSFW
Section titled “Permissions and NSFW”@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.
Localisation
Section titled “Localisation”@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.
With a Cog
Section titled “With a Cog”A Cog groups related handlers into a module. The API mirrors @bot.command, just replace bot with your Cog instance:
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("👋")bot.load_extensions("cogs") # auto-discovers all Cog instances in the packageFiles 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..
Options
Section titled “Options”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.
Attachments
Section titled “Attachments”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}")Subcommands
Section titled “Subcommands”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): ...Permissions on subcommands
Section titled “Permissions on subcommands”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 Membersasync def admin_ban(ctx): ...
@bot.command("admin/purge", description="Purge messages", default_member_permissions=8192) # Manage Messagesasync 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.
Autocomplete
Section titled “Autocomplete”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]Context menu commands
Section titled “Context menu commands”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.
Guards
Section titled “Guards”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.
Error handling
Section titled “Error handling”from cordless import CordlessError, PermissionDeniedError
@bot.errorasync 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.
Allowed mentions
Section titled “Allowed mentions”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 pingsawait ctx.send(f"Hey {user_mention}!", allowed_mentions={"parse": []})
# only ping roles, not usersawait ctx.send(content, allowed_mentions={"parse": ["roles"]})The value is passed directly to Discord. See the Discord docs for all options.
Deferred commands
Section titled “Deferred commands”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)Scheduled handlers
Section titled “Scheduled handlers”@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:
cordless cron hourly_tickSending messages from cron handlers
Section titled “Sending messages from cron handlers”bot exposes Discord REST methods you can call from any cron handler. All methods require DISCORD_BOT_TOKEN in your environment.
# send a messageawait bot.send_message(channel_id, "Hello!")
# send with embeds or componentsawait bot.send_message(channel_id, embeds=[embed])
# edit or deleteawait bot.edit_message(channel_id, message_id, "Updated!")await bot.delete_message(channel_id, message_id)
# role managementawait bot.add_role(guild_id, user_id, role_id)await bot.remove_role(guild_id, user_id, role_id)Registering
Section titled “Registering”# 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 deploycordless deploy --registerCredentials 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 installedasync def subscribe(ctx): ...
@bot.command("purge", guild_ids=["829103480018305044"]) # only that one guildasync def purge(ctx): ...
@bot.command("announce", guild_ids=["111111111", "222222222"]) # a specific set of guildsasync 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.
