Serverless Discord interactions framework for AWS Lambda
Project description
cordless
Build Discord bots that run on AWS Lambda. Discord sends a request, Lambda wakes up, your handler runs, Lambda goes back to sleep. No server to keep alive, no idle cost.
from cordless import Cordless
bot = Cordless()
@bot.command("ping", description="Say hello")
async def ping(ctx):
await ctx.send("pong")
handler = bot.handler()
cordless deploy --register
# → https://abc123.execute-api.eu-west-2.amazonaws.com/
Why cordless?
Most Discord bots run as long-lived processes — a VPS or container that sits idle 99% of the time, waiting for someone to type a command. You pay for uptime whether your bot is busy or not.
cordless flips this. Your bot is a Lambda function: it only runs when Discord sends an interaction, takes milliseconds to respond, and costs essentially nothing to host. One command provisions everything on AWS — IAM role, Lambda function, API Gateway endpoint — and registers your commands with Discord.
- No server — no EC2, no containers, no uptime monitoring, no SSH
- No idle cost — Lambda charges per invocation, not per hour
- One command to ship —
cordless deployhandles all the AWS wiring - Local dev —
cordless devruns your bot on localhost with a live public tunnel - Slow commands — deferred interactions hand off to a worker Lambda so Discord's 3-second limit is never a problem
Install
pip install "cordless[deploy]"
Quickstart
Create lambda_function.py, deploy it to Lambda, and point Discord's Interactions Endpoint URL at your function URL.
import os
from cordless import Cordless
bot = Cordless(public_key=os.environ["DISCORD_PUBLIC_KEY"])
@bot.command("ping", description="Replies with pong")
async def ping(ctx):
await ctx.send("pong")
handler = bot.handler()
Your public key is in the Discord Developer Portal → General Information. Every incoming request is verified automatically — invalid signatures return 401 before your code runs. PING interactions (sent when you first save your endpoint URL) are answered automatically.
Omit
public_keyto skip verification. Fine for local testing; never do it in production.
Verification runs at C speed automatically — cordless deploy bundles PyNaCl into the cordless layer. If it can't be fetched (or you deploy with --bundle-cordless), cordless falls back to a built-in pure-Python Ed25519 implementation, so verification always works.
Local development
cordless dev
Run your bot locally with hot reload, no deploy needed:
cordless dev
cordless scans your source directory for a Cordless() instance automatically. Pass MODULE:ATTRIBUTE explicitly if you have multiple bots or a non-standard layout.
If cloudflared is installed, dev opens a free public tunnel and prints the URL. Paste it into your app's Interactions Endpoint URL and Discord talks to your local code with real, signed interactions. Edit a file, save, run the command again in Discord: changes apply instantly.
Install cloudflared:
- macOS:
brew install cloudflared - Windows:
winget install Cloudflare.cloudflared - Linux: download from the releases page
Deferred handlers (defer=True) run in-process on a background thread, so the full defer flow works without a worker Lambda. .env is loaded automatically.
cordless dev
local http://127.0.0.1:8787
public https://random-words.trycloudflare.com
paste the public url into your app's Interactions Endpoint URL
watching for changes (ctrl+c to stop)
.env
All cordless commands read a .env file in the project root. Values are loaded automatically and merged into your Lambda's environment on deploy — so you only need to define credentials once.
DISCORD_PUBLIC_KEY=abc123...
DISCORD_BOT_TOKEN=your_bot_token
DISCORD_CLIENT_ID=123456789
DISCORD_CLIENT_SECRET=your_secret
Commands
Options
The simplest way: declare options as typed parameters. cordless infers the Discord option types from the annotations 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 (unannotated parameters default to string).
For anything richer — choices, autocomplete, users/channels/roles, min/max — use the option() helper:
from cordless import Cordless, option
@bot.command("echo", description="Repeats text back", options=[
option("text", "What to echo", required=True),
])
async def echo(ctx):
await ctx.send(ctx.options["text"])
Available types: string, integer, number, boolean, user, channel, role, attachment. Extra kwargs map directly to Discord option fields: required, autocomplete, choices, min_value, max_value, min_length, max_length.
For attachment options, the option value is an id — look up the file metadata (filename, url, size) on ctx.attachments:
@bot.command("inspect", options=[option("file", type="attachment", required=True)])
async def inspect(ctx):
att = ctx.attachments[ctx.options["file"]]
await ctx.send(f"{att['filename']} — {att['size']} bytes")
Command names are validated at decoration time: 1-32 lowercase letters, digits, - or _.
Subcommands
Use parent/sub and parent/group/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): ...
Autocomplete
Mark the 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("color", description="Look up a colour", options=[
option("name", "Colour name", autocomplete=True),
])
async def color_cmd(ctx):
await ctx.send(f"Colour: {ctx.options['name']}")
@bot.autocomplete("color", "name")
async def color_ac(ctx):
return ["red", "green", "blue"]
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("color", "name")
async def color_ac(ctx):
query = (ctx.focused_value or "").lower()
return [{"name": c.title(), "value": c} for c in COLORS if c.startswith(query)]
Deferred replies
Discord requires a response within 3 seconds. Use defer=True for slow operations — cordless ACKs Discord immediately, invokes a second Lambda (the worker) in the background, and the worker calls ctx.send() when it's done.
@bot.command("report", description="Generate a report", defer=True)
async def report(ctx):
data = await build_report() # can take as long as needed
await ctx.send(f"Report ready: {data}")
In lambda_function.py, expose the worker handler:
from cordless.worker import make_worker_handler
worker_handler = make_worker_handler(bot)
Set defer_worker in cordless.toml so cordless deploy creates the worker and wires the invoke permission automatically.
Deferred buttons
Buttons can also be deferred — useful when the response takes time. Use defer=True on @bot.button(). cordless responds with a loading state immediately and lets the worker update the message.
@bot.button("slow_action", defer=True)
async def slow_action(ctx):
result = await do_work()
await ctx.edit(f"Done: {result}")
Scheduled handlers
Run code on a schedule with @bot.cron() — daily rewards, cleanup jobs, anything that shouldn't wait for an interaction. cordless deploy wires each schedule to an EventBridge rule automatically.
@bot.cron("rate(1 day)")
async def daily_rewards():
...
@bot.cron("cron(0 12 * * ? *)", name="noon_report")
async def noon():
...
Schedules use EventBridge expressions (rate(...) or cron(...)). Handlers take no arguments and run on the worker Lambda when defer_worker is set, otherwise on the main function.
Context menu commands
Context menu commands appear when a user right-clicks a user or message → Apps. They have no slash-command syntax — just a name.
@bot.user_command("Inspect User")
async def inspect(ctx):
user = ctx.target_user # the right-clicked user
await ctx.send(f"**{user['username']}** — {user['id']}", ephemeral=True)
@bot.message_command("Bookmark")
async def bookmark(ctx):
msg = ctx.target_message # the right-clicked message
await ctx.send(f"Saved: {msg['content'][:100]}", ephemeral=True)
| attribute | description |
|---|---|
ctx.target_user |
Right-clicked user object (user commands) |
ctx.target_member |
Right-clicked guild member (user commands, guild only) |
ctx.target_message |
Right-clicked message object (message commands) |
Buttons
from cordless import ActionRow, Button, ButtonStyle
@bot.command("vote", description="Start a vote")
async def vote(ctx):
await ctx.send("Cast your vote:", components=[
ActionRow([
Button("Yes", custom_id="vote_yes", style=ButtonStyle.SUCCESS),
Button("No", custom_id="vote_no", style=ButtonStyle.DANGER),
])
])
@bot.button("vote_yes")
async def on_yes(ctx):
await ctx.edit("You voted yes.")
ctx.edit() updates the original message in-place. ButtonStyle values: PRIMARY, SECONDARY, SUCCESS, DANGER, LINK. Link buttons take a url= instead of custom_id=.
Dynamic ids match by :-separated prefix — a handler registered as "shop" receives "shop:sword:2", with the suffix segments on ctx.custom_id_args (["sword", "2"]). This works for buttons, selects, and modals.
Select menus
from cordless import ActionRow, StringSelect, SelectOption
@bot.command("pick", description="Pick a colour")
async def pick(ctx):
await ctx.send("Choose:", components=[
ActionRow([StringSelect("colour_select", [
SelectOption("Red", "red"),
SelectOption("Green", "green"),
SelectOption("Blue", "blue"),
], placeholder="Pick one")])
])
@bot.select("colour_select")
async def on_colour(ctx):
await ctx.edit(f"You picked {ctx.values[0]}")
Selected values are on ctx.values as a list. Also available: UserSelect, RoleSelect, MentionableSelect, ChannelSelect.
Modals
from cordless import Modal, TextInput, TextInputStyle
@bot.command("feedback", description="Leave feedback")
async def feedback_cmd(ctx):
await ctx.send_modal(Modal(
"feedback_modal", "Leave Feedback",
TextInput("subject", "Subject", style=TextInputStyle.SHORT),
TextInput("body", "Message", style=TextInputStyle.PARAGRAPH, required=False),
))
@bot.modal("feedback_modal")
async def on_feedback(ctx):
subject = ctx.modal_values["subject"]
body = ctx.modal_values.get("body", "")
await ctx.send(f"**{subject}**\n{body}", ephemeral=True)
Submission values land in ctx.modal_values as a {custom_id: value} dict.
Embeds
from cordless import Embed
embed = (
Embed(title="Status", description="All systems operational", color=0x57AB5A)
.set_author("cordless", icon_url=icon_url)
.set_footer("Last checked just now")
.add_field("Uptime", "99.9%", inline=True)
.add_field("Region", "eu-west-1", inline=True)
)
await ctx.send(embeds=[embed])
Components v2
Discord's UI Kit — richer layouts with Container, Section, TextDisplay, Thumbnail, and Separator. The 32768 flag is set automatically when any of these appear in your response.
from cordless import Container, Section, TextDisplay, Thumbnail, Separator
await ctx.send(components=[
Container([
Section(
TextDisplay(f"**{user['username']}**\n-# joined {joined_at}"),
accessory=Thumbnail(avatar_url(user)),
),
Separator(divider=True, spacing=1),
TextDisplay(f"-# User ID: {user['id']}"),
], accent_color=0x5865F2)
])
Section can take a Button as its accessory instead of a Thumbnail. -# in text produces subtext (smaller, muted).
Error handling
Register a single global handler with @bot.error. It catches any unhandled exception from any command, button, modal, or select handler.
@bot.error
async def on_error(ctx, exc):
await ctx.send(f"Something went wrong: {exc}", ephemeral=True)
Guards
Guards run before a handler. Raise PermissionDeniedError to block it — the error propagates to your @bot.error handler.
from cordless.errors import PermissionDeniedError
def admin_only(ctx):
if not is_admin(ctx.user):
raise PermissionDeniedError("Admins only.")
@bot.guard(admin_only)
@bot.command("ban", description="Ban a user")
async def ban(ctx): ...
Cogs
Split related handlers into modules with Cog. The API mirrors @bot.command — just replace bot with a 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.
Deploying
cordless init
Scaffolds a new bot in the current directory: lambda_function.py, cordless.toml, and .env.example. Existing files are left alone.
cordless init my-bot
cordless deploy
Packages your source directory, creates (or updates) the Lambda function and a cordless layer, sets up API Gateway, and returns the endpoint URL.
cordless deploy
# with a deferred worker Lambda
cordless deploy --defer-worker my-bot-worker
# deploy and register slash commands in one step
cordless deploy --register
--register auto-detects your Cordless() instance and reads credentials from $DISCORD_BOT_TOKEN, or $DISCORD_CLIENT_ID + $DISCORD_CLIENT_SECRET (from env or .env).
cordless destroy
Deletes everything cordless deploy created: the function(s), API Gateway, EventBridge cron rules, CloudWatch log groups, and the IAM role. Asks for confirmation unless you pass --yes.
cordless destroy
# also delete the cordless Lambda layer
cordless destroy --layer
# specify a layer name
cordless destroy --layer my-layer-name
cordless register
Pushes your bot's slash commands to Discord. Run this once after deploying, and again whenever you add or change commands. cordless auto-detects your Cordless() instance — no need to pass it explicitly.
cordless register
# guild-specific (instant — no propagation delay)
cordless register --guild-id 123456789
Credentials are read from $DISCORD_BOT_TOKEN, or $DISCORD_CLIENT_ID + $DISCORD_CLIENT_SECRET (from env or .env).
cordless logs
Tail CloudWatch logs for your deployed function.
cordless logs
cordless logs --follow
cordless logs --since 30 # last 30 minutes
cordless.toml
Put a cordless.toml in your project root to avoid passing flags on every deploy.
[deploy]
function = "my-bot"
region = "eu-west-1"
runtime = "python3.12"
handler = "lambda_function.handler"
timeout = 10 # seconds (default: 10)
memory = 256 # MB — main function (default: 256)
defer_worker = "my-bot-worker"
defer_timeout = 30 # seconds (default: 30)
defer_memory = 256 # MB
packages = ["pillow"] # extra pip packages to bundle into the zip
policies = ["arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess"]
[deploy.env]
DISCORD_PUBLIC_KEY = "abc123..."
AWS credentials are read from the standard chain — environment variables,
~/.aws/credentials, or an instance role.
Context reference
Every handler receives a ctx object.
Attributes
| attribute | description |
|---|---|
ctx.user |
User who triggered the interaction |
ctx.guild_id |
Guild ID, or None in DMs |
ctx.channel_id |
Channel ID |
ctx.options |
Command options as {name: value} |
ctx.custom_id |
Custom ID of the button or select that fired |
ctx.custom_id_args |
Suffix segments when matched by prefix ("shop:x" → ["x"]) |
ctx.values |
Selected values from a select menu |
ctx.attachments |
Resolved attachment metadata, keyed by attachment id |
ctx.modal_values |
Modal submission as {custom_id: value} |
ctx.focused_value |
Current value of the focused autocomplete option |
ctx.target_user |
Right-clicked user (user context menu commands) |
ctx.target_member |
Right-clicked guild member (user context menu, guild only) |
ctx.target_message |
Right-clicked message (message context menu commands) |
ctx.interaction_id |
Interaction ID |
ctx.token |
Interaction token |
ctx.interaction |
Raw interaction payload |
Methods
| method | description |
|---|---|
await ctx.send(msg, *, content, ephemeral, embeds, components, files) |
Reply with a new message |
await ctx.edit(msg, *, content, embeds, components, files) |
Edit the original message (buttons / selects) |
await ctx.defer(ephemeral=False) |
ACK within 3 s; respond later via the worker |
await ctx.defer_edit() |
ACK a component interaction without editing the message |
await ctx.send_modal(modal) |
Open a modal form |
await ctx.respond_autocomplete(choices) |
Return autocomplete suggestions |
await ctx.followup(msg, …, files=[(name, bytes), …]) |
Send a followup message (deferred worker, post-ACK) |
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file cordless-1.0.0b3.tar.gz.
File metadata
- Download URL: cordless-1.0.0b3.tar.gz
- Upload date:
- Size: 62.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.26 {"installer":{"name":"uv","version":"0.11.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7d77bbcfb4a2dd7cca2d379c51485b361d39d4a75de742c68773500ec05a263d
|
|
| MD5 |
83eeeceeea582059908d05bfb147c857
|
|
| BLAKE2b-256 |
a189d06a275212a120aab7d27f1ac6a2a1d82a75bf11fa0110d3920e3eecae6b
|
File details
Details for the file cordless-1.0.0b3-py3-none-any.whl.
File metadata
- Download URL: cordless-1.0.0b3-py3-none-any.whl
- Upload date:
- Size: 45.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.26 {"installer":{"name":"uv","version":"0.11.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8c37b9a64d550c7ba33e047a7f6aea6f414f5666752124cfd92a4a867ea293b4
|
|
| MD5 |
a41c9e41d8aa5dfc9e6f5abc68949b29
|
|
| BLAKE2b-256 |
9f576246ccacda8dd2a16fc487baa06d7295e946f93e99d1edf038f742105334
|