Install
openclaw skills install @trycaspian/caspianGive an AI agent real communication channels — Slack, Discord, Telegram, email, X, SMS — behind one on_message handler via caspian-sdk. Free channels need no signup; the skill mints a sandbox API key itself.
openclaw skills install @trycaspian/caspianYou are integrating messaging into an AI agent using caspian-sdk. It gives an
agent a real presence on many channels behind ONE on_message handler and one
message.reply() - the same code answers on every channel. The gateway is
hosted at https://api.trycaspianai.com.
The developer may just say "add caspian-sdk" without naming channels. Do this:
curl -s https://api.trycaspianai.com/v1/channels -H "Authorization: Bearer $CASPIAN_API_KEY"
That returns the live channels (currently: email, discord, slack, x,
telegram, phone/SMS). Phone/SMS is BRING-YOUR-OWN (the developer's own
Twilio/Telnyx number - see that section).
Only offer those. Other channels (WhatsApp, Voice, RCS, Instagram, Facebook,
iMessage) exist in the SDK but are NOT live on this gateway yet - do NOT try
to connect them (they 400). If a developer asks for one of those, tell them
it's "coming soon / being integrated" - don't attempt it and don't
apologize at length; just say it's on the way. A channel may occasionally
appear MORE THAN ONCE - each row is a different provider for that channel;
when that happens, ask which provider they want and pass it as provider=
to connect_*().Every channel uses the SAME handler from step 3 - adding one is just another
client.connect_*() call, never new handler code.
If CASPIAN_API_KEY is already in the project's .env, SKIP this step.
Free channels need NO signup. Email, Telegram, Discord, Slack, Instagram, Facebook, and the developer's own Twilio/Telnyx number cost nothing — just mint an instant key and start building:
curl -s -X POST https://api.trycaspianai.com/v1/projects/sandbox \
-H 'Content-Type: application/json' -d '{"name":"my-agent"}'
Returns {"project_id":"...","api_key":"comm_sandbox_..."}. Write it to .env:
CASPIAN_API_KEY=<api_key>
CASPIAN_BASE_URL=https://api.trycaspianai.com
Free channels need no sign-in. Paid channels need a one-time sign-in, then credit. Free channels (email, Telegram, Discord, Slack) work instantly with just the API key. Channels on Caspian's paid network (X, WhatsApp, iMessage) are gated on two things, both surfaced as machine-actionable JSON:
a) One-time developer sign-in (401 account_required). The first paid action
returns 401 with login_options. Paid spend must be tied to a real account.
Get the developer signed in once (they never sign in again after this):
POST https://api.trycaspianai.com/v1/auth/device/start with {"api_key": "<your key>"} (so THIS
project binds to their account - nothing you built is lost).verification_uri_complete and WAIT for them to sign in.POST https://api.trycaspianai.com/v1/auth/device/token with {"device_code": ...} until
status is approved, then retry the call.b) Prepaid credit (402 insufficient_credit). Once signed in, a paid action
returns 402 with payment_options if the balance is too low:
GET https://api.trycaspianai.com/v1/billing (Bearer auth).Add credit to keep {channel} running: https://dashboard.trycaspianai.com
GET https://api.trycaspianai.com/v1/billing until
balance_cents rises (or watch for the billing.credited event), then retry
the call that 402'd.Autopay + spend limits (set them, they protect the developer): after one checkout the card is saved automatically. The agent can then enable replenishment and caps over the API - a monthly cap is REQUIRED for autopay:
curl -s -X PUT https://api.trycaspianai.com/v1/billing/autopay -H "Authorization: Bearer $CASPIAN_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"threshold_cents":500,"topup_cents":2000,"monthly_cap_cents":10000}'
PUT https://api.trycaspianai.com/v1/billing/limits sets monthly_cap_cents / per-channel
channel_caps. Watch for billing.low_balance and billing.limit_reached
events on the normal event stream and tell the developer when they fire.
uv add caspian-sdk
# or: pip install caspian-sdk
The SDK reads CASPIAN_API_KEY and CASPIAN_BASE_URL from the environment or ./.env automatically. Write the handler ONCE; it fires for whatever channels you connect below.
from caspian_sdk import CommClient
client = CommClient()
# Connect the channels the developer chose (see the per-channel sections).
# Email needs nothing and is the safe default:
email = client.connect_email(username="scout") # ASK the developer for the name
print("Agent email:", email["address"]) # scout@agents.trycaspianai.com
# client.connect_telegram(bot_token=...) # if they want Telegram
# client.connect_discord(bot_token=...) # if they want Discord
@client.on_message
def handle(message):
# Same for every channel: message.text, message.sender, message.conversation_id
# (conversation_id is stable per thread - key your agent's memory on it)
answer = your_agent_logic(message.text)
message.reply(answer) # replies on whichever channel the message arrived from
client.listen()
Wire your_agent_logic to whatever the developer's agent framework is
(OpenAI Agents SDK, LangGraph, plain LLM call). The reply must be plain text.
Each channel behaves differently - Slack threads, WhatsApp's 24h window, SMS length limits, iMessage has no markdown, X caps public replies at 280. Caspian returns concise etiquette for the channels the developer connected; inject it into the agent's system prompt so replies fit each platform:
guide = client.behavior_prompt() # combined guide for connected channels
system_prompt = system_prompt + "\n\n" + guide
On any SDK version you can fetch it directly instead:
curl -s https://api.trycaspianai.com/v1/behavior-prompt -H "Authorization: Bearer $CASPIAN_API_KEY"
(or one channel: https://api.trycaspianai.com/v1/channels/slack/guide). It's a suggestion - the
developer can use it as-is, tweak it, or write their own. Offer it; don't force it.
STOP before connecting email - you MUST ask the developer what mailbox NAME they
want (the part before the @) and WAIT for their answer. Do NOT call a bare
connect_email() - that auto-generates an ugly random name like
scout-a3f9@... and is almost never what they want. Once they tell you the name:
client.connect_email(username="<their choice>") -> @agents.trycaspianai.com.
If that name is taken the API replies 409 with a suggestions list of readable
alternatives - show those and let them pick one. If they'd rather use their OWN
domain, follow the custom-domain section and pass domain= too (e.g.
username="kernel", domain="agents.acme.com" -> kernel@agents.acme.com).
With the integration running in one terminal, deliver a test email:
curl -s -X POST https://api.trycaspianai.com/v1/test-emails -H "Authorization: Bearer $CASPIAN_API_KEY" \
-H 'Content-Type: application/json' -d '{"text":"hello, are you alive?"}'
Within ~10 seconds the running integration must print the inbound message and send a reply. Confirm by checking events:
curl -s "https://api.trycaspianai.com/v1/events?type=message.sent" -H "Authorization: Bearer $CASPIAN_API_KEY"
If a message.sent event appears, the integration is complete. Tell the
developer their agent's email address; real emails to it from any mail client
now reach the handler.
If the developer sends a REAL email and says no reply arrived, FIRST tell them to
check their SPAM/Junk folder. A message.sent event (above) means the reply WAS
sent - a brand-new sending domain has no reputation yet, so Gmail/Outlook often
file the first replies as spam. Ask them to open it and click "Not spam"; that
trains the inbox and later replies land normally. This is deliverability warmup,
not a bug. Only if there's no message.sent event is something actually wrong.
By default addresses live on the platform domain. If the developer wants their own brand (e.g. support-x@agents.acme.com), ask them:
Do you want agent email addresses on your own domain? If yes, tell me a dedicated subdomain of a domain you control (e.g. agents.yourcompany.com). Do not use your root domain - its existing mail would be affected.
Then:
client.add_domain("agents.acme.com") (REST: POST https://api.trycaspianai.com/v1/domains
with {"domain": "agents.acme.com"}).dns_records and tell them to add each at
their DNS provider. A zone file for bulk import is at
https://api.trycaspianai.com/v1/domains/{id}/zone-file.GET https://api.trycaspianai.com/v1/domains/{id} until status is active (DNS can take
minutes). Do not guess; wait for the developer to confirm the records are in.client.connect_email(domain="agents.acme.com") -
the address is now on their domain, sending is DKIM-signed as their domain.
To pick the exact address, add username="kernel" ->
kernel@agents.acme.com (custom domains only; 409 if taken).Slack comes THREE ways - STOP and ask the developer:
Slack connects as an app installed to a workspace. Three options: a) Quick (one click) - add our shared app; nothing to create. Custom name + icon, so it still looks like your brand. b) Branded - your OWN Slack app in YOUR workspace (~5 min); you control the app name and the @mention. c) Distribute - your OWN Slack app that you PUBLISH and install into your CUSTOMERS' workspaces (the same way Caspian does its shared app). ~10 min once, then one "Add to Slack" link per customer. Which - Quick, Branded, or Distribute?
Ask the developer what name (and optional icon URL) the agent should post under:
What should the agent be called in Slack? (e.g. "Acme Support") Optionally an icon image URL too.
Then: result = client.install_slack(display_name="<their name>", icon_url="<url>")
returns a connection with an authorize_url ("Add to Slack"). Give the developer
that URL: "Open this, pick your workspace, and authorize." The shared app installs
into their workspace, the connection flips to active on its own (poll
GET https://api.trycaspianai.com/v1/connections/{id} or watch for connection.active), and the agent
posts under their name + icon. Messages in that workspace reach the same
on_message handler. If install_slack() 400s, the shared app isn't configured on
this gateway - use (b). NEVER default the name to "Caspian"/an infra name - ask.
Both use an app the developer OWNS; (c) just also turns on public distribution so they can install it into OTHER workspaces. Walk the developer through creating it - tell them to do EXACTLY this at api.slack.com/apps -> Create New App -> From scratch:
https://api.trycaspianai.com/v1/oauth/slack/callback, Save.https://api.trycaspianai.com/internal/providers/slack/webhooks (it verifies instantly). Under
"Subscribe to bot events" add: message.channels, message.im, app_mention, Save.Then connect:
client.connect_slack(slack_client_id=..., slack_client_secret=..., slack_signing_secret=...) returns a connection with an authorize_url. Open it,
pick your workspace, Allow -> the connection goes active.connect_slack(...) with the SAME app creds but a DIFFERENT
customer_id for each customer (ask the developer for a label per customer, e.g.
their company name). Each call returns its OWN authorize_url ("Add to Slack").
Send that link to that customer; they pick THEIR workspace and Allow. Every
customer's workspace routes back to the same on_message handler, isolated by
customer_id, and inbound from all of them shares the one events URL - routed
automatically by app + team.CLI: caspian connect slack prints the authorize link.
Same OAuth-link pattern (gateway-configured app):
client.connect_instagram(...) / connect_facebook(...) returns a connection
with an authorize_url.GET https://api.trycaspianai.com/v1/connections/{id} until active.on_message handler now answers on that channel.The agent connects to Discord as a bot (two-way: people message it, it replies). There are two ways to get that bot - STOP and ask the developer:
Discord connects as a bot. Two options: a) Quick (one click) - add our shared bot to your server; no token to create. You give it a custom name; it uses a shared avatar. b) Branded - create your OWN bot (~5 min) so it has your own name AND avatar. Which - Quick (shared) or Branded (your own)?
First ASK the developer what name the bot should show in their server:
What should the bot be called in your server? (e.g. "Acme Support")
Then pass it as display_name:
result = client.install_discord(display_name="<their chosen name>") returns a
connection with an authorize_url. Give the developer that URL: "Open this, pick
your server, and authorize." Discord adds the shared bot, renames it to their
chosen name in that server, and the connection flips to active on its own (poll
GET /v1/connections/{id} or watch for connection.active). Messages in that
server then reach the same on_message handler; message.reply() answers. If
install_discord() 400s, the shared bot isn't configured on this gateway - use
(b). A 409 on the callback means that server is already linked to an agent. (The
custom name is a per-server nickname; avatar stays shared - use (b) for a custom
avatar too.) NEVER default the name to "Caspian" or an infra name - ask.
Create a bot at discord.com/developers -> New Application -> Bot -> Reset Token -> copy it. Enable the "Message Content Intent" under Bot settings. Then paste me the token.
Then client.connect_discord(bot_token="<token>"). Same on_message handler
answers; message.reply() replies in Discord. A 409 means that bot is already
connected elsewhere - use a different one.
Either way, replies use the developer's agent - only the bot's name/avatar differs (shared name vs their own bot). Never brand the bot as Caspian/the gateway.
Telegram needs one thing only a human can create: a bot token. STOP and ask the developer for it with exactly these instructions:
Open Telegram and message @BotFather. Send /newbot, choose a name and a username, and paste me the token it gives you (looks like 7123456789:AAE...).
Do not proceed without the token and never invent one. Once the developer provides it:
telegram = client.connect_telegram(bot_token="<token from developer>")
print("Telegram bot:", telegram["address"]) # @their_bot
Or via CLI: caspian connect telegram --bot-token <token>.
The same @client.on_message handler receives Telegram messages; message.reply() answers in the chat. No other setup - the gateway registers the webhook.
To verify: tell the developer to open Telegram, search for the bot's @username, and send it any message. The running integration must print it and reply within a few seconds.
Error handling: a 409 from connect means that bot token is already connected
elsewhere - ask the developer for a different bot. A 422 naming bot_token
means it was missing or malformed.
WhatsApp is served by TWO providers on this gateway, so it appears twice in /v1/channels. They behave differently, so STOP and ask the developer which one with exactly this choice:
WhatsApp has two options here:
- Twilio - fastest to test. A shared sandbox number works in minutes (great for a demo); a dedicated production number needs a paid Twilio account + WhatsApp sender approval.
- Meta (direct) - connect your OWN branded WhatsApp Business number through a one-time Meta popup (Embedded Signup). Best for production and brand. Which do you want - Twilio (quick test) or Meta (branded)?
Pass their choice as provider= on connect. Same on_message handler either way.
wa = client.connect_whatsapp(provider="twilio-whatsapp")
print("WhatsApp on:", wa["address"]) # the Twilio number (sandbox by default)
For inbound to reach the agent, the developer must do two one-time things in the Twilio Console (Messaging -> Try it out -> Send a WhatsApp message):
https://api.trycaspianai.com/internal/providers/twilio-whatsapp/webhooks (HTTP POST), and Save.join <two-words> to the sandbox
number (+1 415 523 8886) to opt in.Then any message they send that number reaches the same handler and gets a reply.
A one-time Meta popup connects the developer's (or their customer's) own WhatsApp Business number - no tokens to copy or paste:
session = client.start_whatsapp_onboarding()
print("Open this to connect WhatsApp:", session["launcher_url"])
Give the developer that launcher_url. They click through the Meta popup once
and their number provisions onto the agent. Poll GET https://api.trycaspianai.com/v1/connections/{id}
until status is active (or watch for the connection.active event). If
start_whatsapp_onboarding() returns 400, Embedded Signup isn't configured on
this gateway yet - fall back to Option 1 (Twilio).
Either way, WhatsApp business rules apply: the agent can reply freely for 24h
after a user messages it; starting a conversation cold needs an approved
template. The developer's on_message code doesn't change - the gateway handles
the difference.
The agent gets a real phone line for two-way SMS. It's BRING-YOUR-OWN: the developer uses their OWN Twilio or Telnyx account + a number they own there, so their account handles billing and A2P/10DLC registration - Caspian just relays. STOP and ask which provider:
SMS runs through your own CPaaS account. Which do you use - Twilio or Telnyx? You'll need your account credentials and a phone number you own there.
Ask for: Account SID + Auth Token (console.twilio.com) and your Twilio number (E.164).
sms = client.connect_phone(provider="twilio",
account_sid="AC...", auth_token="...", from_number="+1...")
Then tell the developer to set their Twilio number's inbound "A message comes in"
webhook (Messaging config) to:
https://api.trycaspianai.com/internal/providers/twilio/webhooks/<your +E.164 number>
Ask for: API Key (portal.telnyx.com) and your Telnyx number (E.164).
sms = client.connect_phone(provider="telnyx", api_key="KEY...", from_number="+1...")
Then set that number's inbound webhook to:
https://api.trycaspianai.com/internal/providers/telnyx/webhooks/<your +E.164 number>
The same on_message handler answers SMS; message.reply() texts back; it can
also initiate (cold-start) a text. A 409 means that number is already connected.
Note: US outbound SMS needs A2P 10DLC (or a verified toll-free) on THEIR Twilio/
Telnyx account - that's on the developer's CPaaS account, not this gateway.
iMessage needs NOTHING from the developer - the gateway owns the number (via an AgentPhone relay, since Apple has no public API). Just connect:
imessage = client.connect_imessage()
print("iMessage number:", imessage["address"])
The same on_message handler answers over iMessage. It also supports
cold-initiate (message a number that never messaged first), unlike WhatsApp.
To verify: text the printed number from an iPhone - the running integration prints the inbound message and replies within a few seconds.
Note: this gateway shares one iMessage number, so it's one connection at a time (fine for a demo; per-agent numbers are a follow-up). A 409 / "already connected" on connect means another agent currently holds it.
The agent runs an X account as a bot: people DM the account, the agent replies. REACTIVE only - it answers DMs it receives, never cold-DMs (X ToS). The account should be labelled "Automated" in X settings. Two ways - STOP and ask:
X connects your account as a DM bot. Two options: a) Quick (one click) - authorize with "Sign in with X"; nothing to paste. b) Branded - paste your own X app's tokens (create one at developer.x.com).
result = client.install_x()
print("Authorize here:", result["authorize_url"])
Give the developer the authorize_url, or open it. It says "Sign in with X ->
authorize." IMPORTANT - tell the developer clearly:
Whatever X account you are LOGGED INTO when you click "Authorize" becomes the bot - that handle is what your users will DM. If you don't want your personal account to be the bot, create a dedicated X account first (e.g. @yourbrandbot), log into it, THEN open this link.
Once they approve, that account becomes the bot and goes active. Then just run the
same on_message handler + client.listen(). If install_x() 400s, the shared X
app isn't configured on this gateway - use option (b).
Ask the developer for their app's Access Token, Access Token Secret, and numeric user id (from developer.x.com, Keys & Tokens tab; App permissions "Read and write and Direct message"). The user id is the part before the dash in the access token.
x = client.connect_x(
access_token="<Access Token>",
access_secret="<Access Token Secret>",
user_id="<numeric user id>",
)
Either way: the same on_message handler answers X DMs and message.reply()
replies in the same DM thread. Inbound arrives by polling (a few seconds' latency,
not instant) - no webhook to set up. A 409 means that X account is already
connected to another agent.
To verify: DM the connected account from any other X account - the running integration prints the inbound DM and replies within ~10 seconds. Note: X DM reads/sends are pay-per-use, so the account needs a small credit balance.
connect_email() is idempotent: calling it again (e.g. on agent restart)
returns the same connection and address instead of provisioning a new inbox.connect_email() with no arguments uses a default customer/agent scope.
For multi-tenant products pass explicit customer_id and agent_id
(create them via client.create_customer(name) / client.create_agent(name)).client.listen() blocks forever. For custom loops poll
client.events(after_seq=cursor); events are strictly ordered by seq.listen() already dedupes within
a run. Persist your own cursor if you need replies across restarts.message.typing() again to keep it alive (it lasts ~5-10s per call).client.listen(ack="On it, one moment…") to send an instant acknowledgement
reply the moment a message arrives, so the human knows the agent is working
while it thinks; the real answer follows from the handler. (caspian-sdk >= 0.1.2;
JS: client.listen({ ack: "On it…" }).)