Skip to content

Templates

Markdown

Each template is a small server with no dependencies: it verifies the signature of every request, answers within 3 seconds, and does its work afterwards. They are the code the guides describe, and the same files are tested against a real workspace.

Echo bot

Repeats what people write to it, in the thread of the message. Start here for anything that answers messages or mentions.

Slash command bot

Answers /deploy staging: checks who may run it, starts the work, replies privately and tells the channel.

Needs chat:write, the message.im and app_mention events, and Allow people to message this app under App Home. See the quickstart for the setup.

bot.mjs
// An Amedeus app that answers when someone writes to it or mentions it. Node 22, no dependencies.
//
// SIGNING_SECRET=… BOT_TOKEN=… node bot.mjs
//
// Amedeus calls this server for every event the app subscribed to. Verify the signature, answer 200
// within 3 seconds, and do the work afterwards.
import crypto from "node:crypto";
import http from "node:http";
const { SIGNING_SECRET, BOT_TOKEN, PORT = "3000" } = process.env;
const API = process.env.AMEDEUS_API ?? "https://amedeus.app/api/public/apps/v1";
if (!SIGNING_SECRET || !BOT_TOKEN) {
console.error("Set SIGNING_SECRET and BOT_TOKEN (see .env.example).");
process.exit(1);
}
function isFromAmedeus(headers, rawBody) {
const timestamp = headers["x-amedeus-request-timestamp"];
// Refuse old requests, so a captured one can't be replayed.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected =
"v0=" +
crypto.createHmac("sha256", SIGNING_SECRET).update(`v0:${timestamp}:${rawBody}`).digest("hex");
const given = String(headers["x-amedeus-signature"] ?? "");
return (
given.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))
);
}
// One message can arrive as more than one event (a mention in a channel is both `message.channel`
// and `app_mention`), and a retry repeats an event. Answer each message once.
const answered = new Set();
function firstTime(messageId) {
if (answered.has(messageId)) return false;
answered.add(messageId);
// Keep the memory bounded: forget the oldest once there are a thousand.
if (answered.size > 1000) answered.delete(answered.values().next().value);
return true;
}
async function reply(event) {
const response = await fetch(`${API}/messages`, {
method: "POST",
headers: { Authorization: `Bearer ${BOT_TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify({
channel: event.channel,
// In a channel, answer in the thread of the message; in a direct message, just answer.
parent_id: event.channel_kind === "dm" ? undefined : (event.parent_id ?? event.message_id),
text: `You said: ${event.text}`,
}),
});
// Every error has a stable `error` code.
if (!response.ok) console.error("Could not reply:", await response.json());
}
http
.createServer((req, res) => {
let rawBody = "";
req.on("data", (chunk) => (rawBody += chunk));
req.on("end", () => {
if (!isFromAmedeus(req.headers, rawBody)) return res.writeHead(401).end();
const payload = JSON.parse(rawBody);
// Amedeus checks your URL with a challenge when you save it.
if (payload.type === "url_verification") return res.writeHead(200).end(payload.challenge);
// Answer within 3 seconds, then do the work.
res.writeHead(200).end("ok");
if (payload.type === "event_callback" && firstTime(payload.event.message_id))
reply(payload.event).catch((error) => console.error("Could not reply:", error));
});
})
.listen(Number(PORT), () => console.log(`Listening on :${PORT}`));

Download the project (bot.mjs, package.json, .env.example, Dockerfile, README.md):

Terminal window
for f in bot.mjs package.json .env.example Dockerfile README.md; do
curl -O "https://docs.amedeus.app/templates/echo-bot-node/$f"
done

Needs the commands permission and a command registered under Slash commands (/deploy). The bot allows only the user ids you list, answers refusals and usage hints privately, and tells the channel when a deploy starts. Without DEPLOY_WEBHOOK_URL it is a dry run, so you can try it safely.

bot.mjs
// An Amedeus app that answers a slash command: `/deploy staging`. Node 22, no dependencies.
//
// SIGNING_SECRET=… ALLOWED_USER_IDS=<your user id> node bot.mjs
//
// Amedeus sends the command here, signed. Verify it, answer 200 within 3 seconds, then reply through
// the command's `response_url`: privately to the person who ran it, or to the whole channel.
import crypto from "node:crypto";
import http from "node:http";
const { SIGNING_SECRET, PORT = "3000", DEPLOY_WEBHOOK_URL } = process.env;
// Who may deploy: a comma-separated list of Amedeus user ids. Everyone else gets a private refusal.
const ALLOWED_USER_IDS = (process.env.ALLOWED_USER_IDS ?? "").split(",").filter(Boolean);
const ENVIRONMENTS = (process.env.ENVIRONMENTS ?? "staging,production").split(",");
if (!SIGNING_SECRET) {
console.error("Set SIGNING_SECRET (see .env.example).");
process.exit(1);
}
function isFromAmedeus(headers, rawBody) {
const timestamp = headers["x-amedeus-request-timestamp"];
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected =
"v0=" +
crypto.createHmac("sha256", SIGNING_SECRET).update(`v0:${timestamp}:${rawBody}`).digest("hex");
const given = String(headers["x-amedeus-signature"] ?? "");
return (
given.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))
);
}
// The response URL works for 30 minutes and 5 times. `ephemeral` (the default) is read only by the
// person who ran the command; `in_channel` is a message from the app for everyone.
async function respond(command, text, responseType = "ephemeral") {
const response = await fetch(command.response_url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, response_type: responseType }),
});
if (!response.ok) console.error("Could not respond:", await response.json());
}
async function handleCommand(command) {
if (!ALLOWED_USER_IDS.includes(command.user))
return respond(command, "You're not allowed to deploy.");
const environment = command.text.trim();
if (!ENVIRONMENTS.includes(environment))
return respond(command, `Usage: \`/deploy <${ENVIRONMENTS.join(" | ")}>\``);
// Start the deploy: here, a POST to the URL your platform gives you. Without one it is a dry run.
if (DEPLOY_WEBHOOK_URL) await fetch(DEPLOY_WEBHOOK_URL, { method: "POST" });
console.log(`Deploy to ${environment} requested by ${command.user}`);
await respond(command, `**Deploy to ${environment} started.** I'll post here when it finishes.`);
await respond(command, `A deploy to **${environment}** was started.`, "in_channel");
}
http
.createServer((req, res) => {
let rawBody = "";
req.on("data", (chunk) => (rawBody += chunk));
req.on("end", () => {
if (!isFromAmedeus(req.headers, rawBody)) return res.writeHead(401).end();
const payload = JSON.parse(rawBody);
if (payload.type === "url_verification") return res.writeHead(200).end(payload.challenge);
// Answer within 3 seconds, then do the work.
res.writeHead(200).end("ok");
if (payload.type === "slash_command" && payload.command === "/deploy")
handleCommand(payload).catch((error) => console.error("Command failed:", error));
});
})
.listen(Number(PORT), () => console.log(`Listening on :${PORT}`));
Terminal window
for f in bot.mjs package.json .env.example Dockerfile README.md; do
curl -O "https://docs.amedeus.app/templates/slash-command-node/$f"
done

Each folder has a Dockerfile: build it, set the variables of .env.example, and give the container a public HTTPS address. Any host works (a VPS, Dokploy, a serverless container).

If you call the Amedeus API from Python behind Cloudflare, send your own User-Agent, as the templates do: Cloudflare refuses the default one of urllib (error 1010).