Core concepts
Caspian has a small mental model. A project holds connections (one per channel you connect), scoped by customer and agent. Inbound messages group into conversations, and every one of them arrives at a single handler as the same message object. Learn these five nouns and the rest of the SDK is obvious.
Projects
A project is your workspace, identified by an API key. When you mint a sandbox key or sign in, you get a project_id; the SDK reads its key from COMM_API_KEY. Everything below (connections, conversations, messages) lives inside one project. You rarely touch the project directly; it is the boundary that your key authorizes.
Connections
Each channel you connect is a connection: an email inbox, a Slack app install, a Telegram bot, a phone number. Every connect_*() / install_*() call creates one and returns it with a connection_id and a status. Adding a channel is one more connection against the same handler, never new handler code.
from caspian_sdk import CommClient
client = CommClient() # one project, via COMM_API_KEY
inbox = client.connect_email() # a connection
tg = client.connect_telegram(bot_token="123:abc") # another connection
print(inbox["id"], inbox["status"]) # connection_id + "active"import { CommClient } from "caspian-sdk";
const client = new CommClient(); // one project, via COMM_API_KEY
const inbox = await client.connectEmail(); // a connection
const tg = await client.connectTelegram({ botToken: "123:abc" }); // another
console.log(inbox.id, inbox.status); // connectionId + "active"Scope: customer and agent
Every connection is scoped by a customer_id and an agent_id. Omit them and the connection lands on your project's default scope, the right choice for a single-tenant agent. For a multi-tenant product, pass an explicit customer_id per end customer (and an agent_id if one project runs several distinct agents). The same handler still receives everything; inbound is isolated and routed back to the correct customer automatically.
# one Slack app, one connection per customer, same handler, isolated by scope
client.install_slack(customer_id="acme", display_name="Acme Bot")
client.install_slack(customer_id="globex", display_name="Globex Bot")// one Slack app, one connection per customer, same handler, isolated by scope
await client.installSlack({ customerId: "acme", displayName: "Acme Bot" });
await client.installSlack({ customerId: "globex", displayName: "Globex Bot" });Use a stable, meaningful customer_id per end customer (e.g. their company slug). It flows through to every inbound message so your agent always knows who it is talking to.
Conversations
Inbound messages group into conversations, one per thread: a Slack thread, an SMS exchange, an email chain, a Discord DM. Each conversation has a stable conversation_id that does not change across turns. That id is the natural key for your agent's memory: load history by conversation_id before you answer, and write new turns back under the same id.
Key your agent's memory on conversation_id. It is stable per thread across every channel, so one lookup restores the right context whether the human wrote from Slack or SMS.
@client.on_message
def handle(message):
history = memory.get(message.conversation_id, []) # your store, keyed by thread
history.append({"role": "user", "content": message.text})
answer = run_agent(history) # LangGraph / OpenAI / any LLM
history.append({"role": "assistant", "content": answer})
memory[message.conversation_id] = history
message.reply(answer)client.onMessage(async (message) => {
const history = memory.get(message.conversationId) ?? []; // keyed by thread
history.push({ role: "user", content: message.text });
const answer = await runAgent(history); // any LLM / framework
history.push({ role: "assistant", content: answer });
memory.set(message.conversationId, history);
await message.reply(answer);
});You can also read conversations directly: list_conversations() / listConversations() and list_messages(conversation_id) / listMessages(conversationId).
The message object
Whatever the channel, your handler receives one unified message. The fields are identical everywhere, so your handler never branches on the platform:
| Field | Type | Description |
|---|---|---|
id | string | Unique id of this inbound message. reply() threads against it. |
conversation_id | string | Stable thread id: key your agent's memory on it. |
connection_id | string | Which connection (channel install) it arrived on. |
customer_id | string | The customer scope this message belongs to. |
agent_id | string | The agent scope this message belongs to. |
channel | string | Source channel: email, slack, telegram, etc. |
sender | object / null | Who sent it: channel-specific identity (address, handle, user id). |
subject | string / null | Subject line, where the channel has one (email). |
text | string / null | Plain-text body. This is what most agents read. |
html | string / null | HTML body, where the channel provides one (email). |
In JavaScript the same fields are camelCase: conversationId, connectionId, customerId, agentId. The message also carries two methods: reply() and typing().
The one-handler model
You register exactly one handler with on_message / onMessage, and it fires for every connection in the project. Inside it, message.reply() sends your answer back on the channel the message came from: same thread, formatted the way that platform expects. You never pick a transport; the source message carries everything Caspian needs to route the reply.
@client.on_message
def handle(message):
# one handler, every channel: reply() routes back to the source
message.reply(f"You said: {message.text}")
client.listen() # one loop; dispatches to your handler foreverclient.onMessage(async (message) => {
// one handler, every channel: reply() routes back to the source
await message.reply(`You said: ${message.text}`);
});
await client.listen(); // one loop; dispatches to your handler foreverreply() takes text and/or html. Reply with plain text unless the channel is email and you want rich formatting. Do not reply to the same message twice; listen() already dedupes inbound within its loop.
Capabilities
Not every connection can do everything. Each carries a set of capabilities that reflect what the channel (and the account behind it) allows:
| Capability | What it allows |
|---|---|
receive | Inbound messages reach your handler. |
reply | Answer an inbound message on its own thread: message.reply(). |
send | Proactively push into an existing conversation: send_message(conversation_id, ...). |
initiate | Cold-start a brand-new conversation with someone who never wrote first: initiate(connection_id, recipient, text). |
Reactive channels (X DMs, for example) support receive and reply but not initiate, by platform policy. Inspect what a project's connections support with channels() / channels(), which lists the configured transports and their capabilities.
# proactive send into a known conversation (needs the "send" capability)
client.send_message(conversation_id, text="Your build finished.")
# cold-start a new conversation (needs the "initiate" capability)
client.initiate(connection_id, recipient="+15551234567", text="Hi from your agent.")// proactive send into a known conversation (needs the "send" capability)
await client.sendMessage(conversationId, "Your build finished.");
// cold-start a new conversation (needs the "initiate" capability)
await client.initiate(connectionId, "+15551234567", "Hi from your agent.");How it fits together
- Project: your workspace, authorized by one API key.
- Connection: one per channel you connect, scoped by
customer_idandagent_id. - Conversation: a thread with a stable
conversation_id; the key for your agent's memory. - Message: the unified object your handler receives; identical fields on every channel.
- Handler: one
on_message;reply()routes back to the source channel automatically.