Install
The SDK is published as @verdant/bot. Use it from a small process that you own:
local Node, Bun, a VPS, a worker, or any host that can make HTTPS requests.
npm install @verdant/bot
# or
bun add @verdant/bot
# or
pnpm add @verdant/bot
Create a bot inside Verdant, mint a bot token, scope it to the server/feed/channel it needs, then pass the token through an environment variable.
Do not commit bot tokens. Put tokens in your host secret store or a local .env file that is ignored by git.
Quickstart
This posts one feed announcement. The card uses format() so dynamic values are escaped,
while selected variables can still be styled.
import { VerdantBot, card, channel, format, span } from "@verdant/bot";
const bot = new VerdantBot({
token: process.env.VERDANT_BOT_TOKEN!,
});
const release = {
version: "0.0.252",
title: "Desktop updater polish",
};
await bot.feeds.postAnnouncement(
process.env.VERDANT_FEED_ID!,
card()
.title(format("Client {version} released", release, {
version: { color: "success", weight: "bold" },
}))
.description(format("{title} is live.", release, {
title: { color: "info" },
}))
.accent("success")
.richText(
"Launch status: ",
span("healthy", { color: "success", weight: "bold", italic: true }),
".",
)
.chart({
title: "Release smoke metrics",
kind: "bar",
points: [
{ label: "Build", value: 1 },
{ label: "Upload", value: 1 },
{ label: "Announcement", value: 1 },
],
})
.youtube("https://www.youtube.com/watch?v=k1_ODDevbY8", "Release walkthrough")
.button("Discuss release", channel(process.env.VERDANT_CHANNEL_ID!)),
{ idempotencyKey: `release-${release.version}` },
);
Examples
Webhook variables
Webhook payloads are just data. Pull out the fields you want, then map them into the builder.
Keep static copy readable and pass dynamic values through format().
import { card, format } from "@verdant/bot";
export function releaseCard(payload: GitHubReleasePayload) {
const values = {
version: payload.release.tag_name,
name: payload.release.name,
author: payload.release.author.login,
sha: payload.release.target_commitish.slice(0, 8),
};
return card()
.title(format("Client {version} released", values, {
version: { color: "success", weight: "bold" },
}))
.description(format("{name} by {author}", values, {
name: { color: "info" },
author: { color: "muted" },
}))
.accent("success")
.table({
columns: ["Field", "Value"],
rows: [
["Version", format("{version}", values, { version: { color: "success", weight: "bold" } })],
["Commit", format("{sha}", values, { sha: { color: "info" } })],
["Author", format("{author}", values, { author: { color: "muted" } })],
],
});
Rendered example
Client 0.0.252 released
Desktop updater polish by release-bot
| Field | Value |
|---|---|
| Version | 0.0.252 |
| Commit | 8f2a91c4 |
| Author | release-bot |
Channel cards
Feed announcements are good for long-lived posts. Text-channel cards are better for discussion, rankings, status updates, and short automation notices.
await bot.channels.postCard(
process.env.VERDANT_CHANNEL_ID!,
card()
.title("Weekly ranking", { color: "purple", weight: "bold" })
.accent("purple")
.ranking("Top contributors", [
{ label: "Josh", value: 1280, detail: "12 commits" },
{ label: "Release Bot", value: 940, detail: "6 automations" },
]),
{ idempotencyKey: "rankings-week-2026-18" },
);
Flutter feed blocks
The feed builder sends structured JSON that Flutter renders natively. Bots never send raw HTML.
Use .richText() for one editable text block with styled spans, .chart()
for bounded analytics cards, and .youtube() for reviewed YouTube player references.
import { card, invite, span } from "@verdant/bot";
card()
.title("Programmatic feed card", { fontSize: 20, weight: "semibold" })
.summary("Posted through the bot REST API.", { fontSize: 14 })
.accent("#1ee3b6")
.heading("Highlights")
.richText("Status: ", span("healthy", { color: "success", weight: "bold" }))
.bullets(["Scoped token", "Backend validated", "Flutter-native preview"])
.numbered(["Build payload", "POST to feed", "Verify in client"])
.code("await bot.feeds.postAnnouncement(feedId, card);", "ts")
.chart({
title: "Audience split",
kind: "donut",
points: [
{ label: "Desktop", value: 58 },
{ label: "Mobile", value: 24 },
{ label: "Web", value: 18 },
],
})
.youtube("https://www.youtube.com/watch?v=k1_ODDevbY8", "Walkthrough")
.button("Join server", invite(process.env.VERDANT_INVITE_CODE!))
.footer("Generated by a bot", { fontSize: 12 });
Images
Upload images through Verdant first, then place the returned CDN URL in the card.
const image = await bot.uploads.image(
await Bun.file("release-badge.png").arrayBuffer(),
{ filename: "release-badge.png", contentType: "image/png" },
);
await bot.feeds.postAnnouncement(
process.env.VERDANT_FEED_ID!,
card()
.title("Release badge")
.image(image.url, "Release badge")
.text("The image is served from Verdant's CDN."),
);
Gateway
Simple scheduled bots can use REST only. Connect to the bot gateway when your bot needs to appear online or react to events it is allowed to receive.
const ws = new WebSocket("wss://api.verdant.chat/bot-gateway");
ws.onopen = () => {
ws.send(JSON.stringify({
op: "IDENTIFY",
d: {
token: process.env.VERDANT_BOT_TOKEN,
intents: ["FEEDS", "MESSAGES"],
serverIds: [process.env.VERDANT_SERVER_ID],
},
}));
};
ws.onmessage = (event) => {
const frame = JSON.parse(event.data);
if (frame.op === "READY") console.log("bot online");
if (frame.op === "DISPATCH") console.log(frame.t, frame.d);
};
setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ op: "PING", d: {} }));
}
}, 25_000);
Component Reference
| Builder API | Use |
|---|---|
card() / announcement() | Start a new card builder. |
.title(text, style?) | Set the card title. |
.description(text, style?) / .summary() | Add short intro copy below the title. |
.accent(color) / .color(color) | Set the card border/accent color. |
.text(text, style?) | Add a plain text paragraph with optional inline style tokens. |
.richText(...parts) | Add one Flutter-native rich text block with explicit styled spans. |
.heading(text, level?, style?) | Add a section heading. |
.quote(text, style?) | Add a callout block. |
.bullets(items, style?) | Add an unordered list. |
.numbered(items, style?) | Add an ordered list. |
.table({ columns, rows }) | Add structured rows and columns. |
.code(source, language?) | Add a preserved code block. |
.image(cdnUrl, alt?) | Add a Verdant CDN image. |
.divider() | Add a section break. |
.button(label, action, options?) | Add a clickable action button. |
.chart({ title, kind, points }) | Add a bounded analytics chart. Kinds: bar, line, donut, metrics, progress, sparkline. |
.youtube(url, title?) | Add a reviewed YouTube video reference for the Flutter player surface. |
.footer(text, style?) | Add small trailing context. |
.build() / .toJSON() | Create the JSON payload sent to the API. |
Helpers
| Helper | Use |
|---|---|
format(template, values, styles?) | Escape dynamic variables and optionally style selected variables. |
richText(...parts) | Build safe inline-style text for title, summary, footer, or plain text blocks. |
span(text, style) | Color, size, or weight a specific word or phrase. |
escapeMarkdown(value) | Escape untrusted text before placing it in markdown. |
trustedMarkdown(text) | Use text you fully control without escaping it. |
themeColor(name) | Resolve a named color token to a hex color. |
channel(id) | Create a button action that opens a Verdant channel. |
externalUrl(url) | Create a button action that opens an HTTP or HTTPS link. |
invite(code) | Create a button action for a Verdant invite code. |
Styles
Named colors
Use a named token for Verdant-themed cards, or pass an exact #RRGGBB value when a brand color matters.
card()
.accent("success")
.title("Release passed", { color: "success" })
.text("Custom brand color", { color: "#7c3aed" });
Text controls
Use size values xs, sm, md, lg, xl. Use fontSize for an exact 8-48 px value. Use weight values normal, medium, semibold, bold, plus italic and strikethrough booleans.
Publishing your bot
- Create a bot in Verdant and keep its token in a secret store.
- Assign the bot a server access role, then add that role to each feed's publish roles.
- Mint a token with
announcements:writeand the target feed inallowedFeedIds. - Run your bot process anywhere that can reach
https://api.verdant.chat. - Use REST for posting cards and the gateway only when your bot needs live events.
- Use idempotency keys for webhooks, scheduled posts, and retryable jobs.
VERDANT_BOT_TOKEN=... \
VERDANT_BOT_FEED_ID=... \
VERDANT_API_URL=https://api.verdant.chat \
bun run --filter @verdant/bot smoke:feed
Optional env vars are VERDANT_INVITE_CODE, VERDANT_YOUTUBE_URL,
and VERDANT_BOT_IDEMPOTENCY_KEY. The smoke script prints published IDs only,
never the token.
If token auth succeeds but the bot role is missing from the feed publish roles,
the API returns FEED_NO_PUBLISH_PERMISSION. If a channel button points
outside the feed's server, validation rejects the card before publishing.