Python client SDK for the Holt chat app bot API with end-to-end encryption
Project description
Installation
pip install holt
# or from source:
pip install -e /path/to/holt-sdk/python
Quickstart
Send a message
from holt import HoltClient
# Your bot token (30-char string) and RSA-2048 private key as base64(PKCS8 DER)
BOT_TOKEN = "your-30-char-bot-token-here"
PRIVATE_KEY_B64 = "your-base64-pkcs8-der-private-key"
client = HoltClient(
base_url="https://your-holt-instance.example.com",
token=BOT_TOKEN,
private_key_b64=PRIVATE_KEY_B64,
)
# Get bot info
me = client.get_me()
print("Logged in as:", me["username"])
# List channels
channels = client.get_channels()
for ch in channels:
print(ch.id, ch.type, ch.name)
# Send a message (auto-detects channel type, handles E2EE)
result = client.send_message(channel_id="your-channel-id", text="Hello from the bot!")
print("Sent:", result.message_id)
# Get recent messages (auto-decrypts E2EE content)
messages = client.get_messages(channel_id="your-channel-id", limit=20)
for msg in messages:
if msg.decrypt_error:
print("[undecryptable]", msg.decrypt_error)
else:
print(f"{msg.user.username if msg.user else 'unknown'}: {msg.content}")
client.close()
Listen for real-time messages and echo-reply
from holt import HoltClient
BOT_TOKEN = "your-30-char-bot-token-here"
PRIVATE_KEY_B64 = "your-base64-pkcs8-der-private-key"
client = HoltClient(
base_url="https://your-holt-instance.example.com",
token=BOT_TOKEN,
private_key_b64=PRIVATE_KEY_B64,
)
me = client.get_me()
@client.on("message_sent")
def on_message(payload):
msg = payload["message"]
channel_id = payload["channel_id"]
# Ignore messages from the bot itself
if msg.user and msg.user.username==me["username"]:
return
if msg.decrypt_error:
print(f"[{channel_id}] Could not decrypt message from {msg.user.username if msg.user else '?'}")
return
print(f"[{channel_id}] {msg.user.username if msg.user else '?'}: {msg.content}")
# Echo the message back
client.send_message(channel_id, f"Echo: {msg.content}", reply_to=msg.id)
@client.on("typing")
def on_typing(payload):
print(f"{payload.get('username')} is typing in {payload.get('channel_id')}")
print("Listening for events... (Ctrl-C to stop)")
client.run() # blocks; connects SSE stream and dispatches events
Create a DM / join via invite
dm_channel_id = client.create_dm(username="alice")
client.send_message(dm_channel_id, "Hey Alice!")
channel_id = client.join_invite("invite-code-here")
print("Joined:", channel_id)
Error handling
from holt import HoltClient, HoltError
try:
client.send_message("bad-channel", "hello")
except HoltError as e:
print(f"HTTP {e.status}: {e.message}")
if e.rate_limit_reset:
print(f"Rate limited, reset at epoch: {e.rate_limit_reset}")
print("Full body:", e.body)
Buttons and modals
from holt import button, action_row, text_input, BUTTON_STYLE_SUCCESS, BUTTON_STYLE_DANGER
client.send_message(channel_id, "Pick an option:", components=[
action_row(
button("Approve", custom_id="approve", style=BUTTON_STYLE_SUCCESS),
button("Deny", custom_id="deny", style=BUTTON_STYLE_DANGER),
)
])
@client.on("component_interaction")
def on_click(payload):
if payload["custom_id"]=="deny":
client.respond_with_modal(
payload["interaction_id"],
title="Denial reason",
custom_id="deny_modal",
components=[action_row(text_input("reason", "Why are you denying this?", style=2, max_length=500))],
)
else:
client.ack_interaction(payload["interaction_id"])
@client.on("modal_submit")
def on_modal(payload):
print("Reason:", payload["components"]["reason"])
Edit an existing message's buttons with client.edit_message(channel_id, message_id, components=[...]); pass components=[] to clear them, or omit the parameter to leave them unchanged.
Reactions
reaction_id = client.add_reaction(channel_id, message_id, "👍")
client.remove_reaction(channel_id, message_id, reaction_id)
@client.on("reaction_add")
def on_reaction(payload):
print(f"{payload['reaction']['user']['username']} reacted {payload['reaction']['content']}")
add_reaction handles encryption automatically for DM/group channels, the same way send_message 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 message.reactions 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
client.set_commands([
{
"name": "announce",
"description": "Post an announcement",
"options": [
{"name": "message", "description": "Text to post", "type": "string", "required": True, "max_length": 500}
],
}
])
@client.on("interaction_create")
def on_command(payload):
if payload["command"]=="announce":
client.send_message(payload["channel_id"], payload["options"]["message"])
set_commands atomically replaces the bot's entire command set. Use upsert_command/delete_command 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 content you send:
from holt import diagram_block, interactive_block
client.send_message(channel_id, "Here's the flow:\n" + diagram_block("graph TD; A-->B;"))
client.send_message(channel_id, "Try it:\n" + interactive_block(html="<button>Click</button>", js="document.querySelector('button').onclick=()=>alert('hi')"))
Embeds
embed_block(**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 id:
from holt import embed_block
with open("chart.png", "rb") as f:
asset = client.upload_embed_asset(channel_id, f.read(), filename="chart.png", content_type="image/png")
client.send_message(channel_id, embed_block(
title="Weekly report",
description="Traffic is up 12% week over week.",
color=0x6f42c1,
image={"id": asset["id"]},
))
Pass encrypt=True to upload_embed_asset when posting in a DM or encrypted group channel (broadcast channels are always plaintext, so this is ignored there).
Crypto overview
- AES-GCM-256: messages in DM and encrypted group channels are encrypted with a shared per-channel AES key.
- RSA-OAEP: each member's copy of the AES key is wrapped with their RSA-2048 public key (OAEP with SHA-256 and label
parley). - RSA-PSS: every message is signed by the sender's private key so recipients can verify authenticity.
- The SDK handles all of this automatically inside
send_message,edit_message, andget_messages.
Channel types
| Value | Constant | Description |
|---|---|---|
| 1 | CHANNEL_DM |
Direct message (E2EE) |
| 2 | CHANNEL_ENCRYPTED_GROUP |
Encrypted group (E2EE) |
| 3 | CHANNEL_BROADCAST |
Broadcast (plaintext) |
License
MIT
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 holt-0.1.2.tar.gz.
File metadata
- Download URL: holt-0.1.2.tar.gz
- Upload date:
- Size: 11.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
da1dea8d90574994324bd6a71ebbc307a6493ba24ec55462cd246ccefc244b4f
|
|
| MD5 |
5a769cdb19ab2f0558d6b4418789b099
|
|
| BLAKE2b-256 |
60cfd0c02e38032b00ed6148f65fd7b4b1ee12f1b67f9ddbc7f5427756e2e518
|
File details
Details for the file holt-0.1.2-py3-none-any.whl.
File metadata
- Download URL: holt-0.1.2-py3-none-any.whl
- Upload date:
- Size: 12.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e522778f5ce60c71bab361437bef997905743201c9e9a93210805f8ffc9d9bbd
|
|
| MD5 |
a21b859d3d6272a055c67273036ad39a
|
|
| BLAKE2b-256 |
9bf114821888fda6858f84726305236dfb20f0924c5293ec0539362e243780d6
|