@holt-chat/sdk
v0.1.2
Published
Official JavaScript/Node client SDK for the Holt chat bot API with end-to-end encryption
Maintainers
Readme
@holt-chat/sdk
JavaScript/Node client SDK for the Holt chat bot API. Zero runtime dependencies, Node 18+, ESM.
All channel types 1 (DM) and 2 (encrypted group) are fully end-to-end encrypted with AES-GCM-256 wrapped per-member with RSA-OAEP. Messages are signed with RSA-PSS. Type 3 (broadcast) is plaintext.
Install
npm install @holt-chat/sdkQuickstart
Construct the client
import { HoltClient } from "@holt-chat/sdk"
const client = new HoltClient({
baseUrl: "https://your-holt-instance.example",
token: "your-30-char-bot-token",
privateKey: "base64-encoded-pkcs8-der-of-your-rsa-2048-private-key",
})Send a message
const result = await client.sendMessage("channel-id-here", "Hello from the bot!")
console.log(result.message_id)With a reply:
await client.sendMessage("channel-id-here", "Replying!", { replyTo: "original-message-id" })Listen for events and echo-reply
client.on("message_sent", async ({ channel_id, message }) => {
if (!message.plaintext || message.user?.username === "mybot") return
await client.sendMessage(channel_id, `You said: ${message.plaintext}`, {
replyTo: message.id,
})
})
await client.connect()Fetch and decrypt messages
const messages = await client.getMessages("channel-id-here", { limit: 50 })
for (const msg of messages) {
if (msg.decryptError) {
console.log(`[undecryptable message ${msg.id}]`)
} else {
console.log(`${msg.user?.display ?? "unknown"}: ${msg.plaintext ?? msg.content}`)
}
}Other operations
// Who am I
const me = await client.getMe()
// List all accessible channels
const channels = await client.getChannels()
// Open a DM with a user
const dmChannelId = await client.createDM("username")
// Join a server via invite code
const channelId = await client.joinInvite("invite-code")
// Disconnect the SSE stream
client.disconnect()Buttons and modals
import { button, actionRow, textInput, BUTTON_STYLE_SUCCESS, BUTTON_STYLE_DANGER } from "@holt-chat/sdk"
await client.sendMessage(channelId, "Pick an option:", {
components: [
actionRow(
button({ label: "Approve", customId: "approve", style: BUTTON_STYLE_SUCCESS }),
button({ label: "Deny", customId: "deny", style: BUTTON_STYLE_DANGER }),
),
],
})
client.on("component_interaction", async payload => {
if (payload.custom_id === "deny") {
await client.respondWithModal(payload.interaction_id, {
title: "Denial reason",
customId: "deny_modal",
components: [actionRow(textInput({ customId: "reason", label: "Why are you denying this?", style: 2, maxLength: 500 }))],
})
} else {
await client.ackInteraction(payload.interaction_id)
}
})
client.on("modal_submit", payload => {
console.log("Reason:", payload.components.reason)
})Edit an existing message's buttons with client.editMessage(channelId, messageId, null, { components: [...] }); pass components: [] to clear them, or omit the option to leave them unchanged.
Reactions
const reactionId = await client.addReaction(channelId, messageId, "👍")
await client.removeReaction(channelId, messageId, reactionId)
client.on("reaction_add", ({ reaction }) => {
console.log(`${reaction.user?.username} reacted ${reaction.plaintext}`)
})addReaction handles encryption automatically for DM/group channels, the same way sendMessage does. A user (or bot) can add several distinct reactions to the same message; the same emoji added twice is only rejected server-side (409) in broadcast channels, since encrypted content can't be compared server-side — check the message's reactions array yourself before re-adding one you already sent. Each message caps out at 20 distinct reactions and 20 reactions from any one user.
Slash commands
await client.setCommands([
{
name: "announce",
description: "Post an announcement",
options: [
{ name: "message", description: "Text to post", type: "string", required: true, max_length: 500 },
],
},
])
client.on("interaction_create", async payload => {
if (payload.command === "announce") {
await client.sendMessage(payload.channel_id, payload.options.message)
}
})setCommands atomically replaces the bot's entire command set. Use upsertCommand/deleteCommand to manage commands one at a time.
Rich content blocks
Diagrams and sandboxed interactive widgets are just markers inside a message's text, built with helpers and appended to the text you send:
import { diagramBlock, interactiveBlock } from "@holt-chat/sdk"
await client.sendMessage(channelId, "Here's the flow:\n" + diagramBlock("graph TD; A-->B;"))
await client.sendMessage(channelId, "Try it:\n" + interactiveBlock({
html: "<button>Click</button>",
js: "document.querySelector('button').onclick=()=>alert('hi')",
}))Embeds
embedBlock(fields) covers title, description, color, url, author {name, url}, footer {text}, fields [{name, value, inline}], and timestamp. Images and thumbnails aren't plain URLs though — the server stores them as uploaded assets, so upload first and reference the returned asset:
import { embedBlock } from "@holt-chat/sdk"
import { readFile } from "node:fs/promises"
const asset = await client.uploadEmbedAsset(channelId, await readFile("chart.png"), {
filename: "chart.png",
contentType: "image/png",
})
await client.sendMessage(channelId, embedBlock({
title: "Weekly report",
description: "Traffic is up 12% week over week.",
color: 0x6f42c1,
image: asset,
}))Pass { encrypt: true } when posting in a DM or encrypted group channel (broadcast channels are always plaintext, so this is ignored there).
Events
All events are registered with .on(event, handler):
| Event | Payload |
|---|---|
| message_sent | { channel_id, message } (message has .plaintext) |
| message_edited | { channel_id, message } (message has .plaintext) |
| message_deleted | { channel_id, message_id } |
| reaction_add | { channel_id, message_id, reaction } (reaction has .plaintext) |
| reaction_remove | { channel_id, message_id, reaction_id } |
| member_join | { channel_id, user } (channel key invalidated automatically) |
| member_leave | { channel_id, user } (channel key invalidated automatically) |
| typing | { channel_id, username } |
| presence_update | raw payload |
| presence_remove | raw payload |
| interaction_create | { id, channel_id, user, command, options } (slash command invoked) |
| component_interaction | { interaction_id, channel_id, message_id, custom_id, user } (button clicked) |
| modal_submit | { interaction_id, user, components } (modal submitted) |
| error | Error |
Error handling
All API errors throw HoltError:
import { HoltError } from "@holt-chat/sdk"
try {
await client.sendMessage("ch", "hi")
} catch (e) {
if (e instanceof HoltError) {
console.log(e.status, e.message, e.body)
if (e.status === 429) console.log("retry after", e.rateLimitReset)
}
}Crypto protocol
- RSA-OAEP-SHA-256 with label
parley, 2048-bit keys - AES-GCM-256, 12-byte IV, WebCrypto appends 128-bit tag
- RSA-PSS-SHA-256, saltLength 222
- Signed string:
plaintext:channelId:epochSeconds - All base64 is standard (btoa-style,
+/=with padding) - Public keys: 392-char base64 SPKI, wrapped AES keys: 344-char base64, signatures: 344-char base64, IV: 16-char base64
Running tests
node --test test/crypto.test.mjs test/blocks.test.mjs