How the KISS Third-Party Agents Work
A complete tour of src/kiss/agents/third_party_agents/ — 40 files, ~21,000 lines: the framework that connects KISS Sorcar to Slack, WhatsApp, Gmail, Discord, Home Assistant and 30+ other outside services.
Contents
- The big picture
- Life of a message (end to end)
- The shared framework (5 infrastructure files)
- Catalog of all 35 agents
- Team-chat agents
- Personal-messaging agents
- Email agents
- Asian-platform agents
- Machine-to-machine agents
- Smart-home & device agents
- Cross-cutting themes: reliability, security, quirks
1. The big picture
“Third-party agents” are channel adapters: each one teaches the KISS Sorcar AI system how to talk over one outside service — a chat platform (Slack, Discord, Telegram…), a mailbox (Gmail, IMAP), a notification service (ntfy), another AI agent (A2A), or a device (Android phone, Home Assistant, Govee lights). Every adapter can be used in three ways:
- As a toolbox for the AI. Each adapter exposes a set of tools
(functions like
post_message,send_email,ha_call_service) that the language model can call while working on a task. A task like “post the release notes to #general” works because the Slack adapter hands the model 19 Slack tools. - As an inbox. Most adapters can also watch a channel: a cron
job runs
kiss-telegram --channel mychatevery minute, new messages are picked up, each one becomes an AI task, and the task’s answer is posted back as a reply — turning any chat app into a conversational front-end for Sorcar. - As a one-shot CLI. Every adapter installs a command
(
kiss-slack,kiss-gmail,kiss-whatsapp, …) that runs a single interactive task with that adapter’s tools attached.
Crucially, a channel agent never executes AI tasks itself. Every task —
whether started from the CLI or triggered by an incoming chat message — is submitted to the
kiss-web daemon through the public kiss.server.sorcar.run API over a
Unix-domain socket. The daemon builds and runs the actual chat agent (with bash, file-editing and
browser tools), broadcasts live events to any connected web views, and persists the chat. The
channel agent object is just a carrier of channel identity: which tools file to load,
which workspace to use, and where to write the results back.
The four building blocks of every adapter
Each of the 34 channel adapters (all except the standalone
govee.py script) is built from the same four parts:
*ChannelBackend— a subclass ofToolMethodBackendthat owns the transport (HTTP client, WebSocket, subprocess, embedded web server). Any public method on this class automatically becomes an LLM tool; a fixed list of twelve infrastructure methods (connect,poll_messages,send_message,is_from_bot, …) is excluded from tool exposure.*Agent— a subclass ofBaseChannelAgentthat wires the backend, answers_is_authenticated(), and supplies three auth tools (check_*_auth,authenticate_*,clear_*_auth) as closures. Some agents add a fourth “browser setup” tool (Slack, Discord, Gmail) that instructs the model to create API credentials autonomously with its browser tools.main()— one line delegating to the sharedchannel_main(AgentClass, "kiss-<name>", …)CLI entry point.get_tools()— a module-level function returningAgentClass()._get_tools(). This is the tools-file contract: when a channel task runs, the adapter’s own.pyfile path is passed to the daemon, which imports it and callsget_tools()to rebuild the toolbox from the credentials persisted under~/.kiss. No registry or generated glue code exists.
2. Life of a message (end to end)
Here is exactly what happens when someone writes “what’s on my calendar?” in a monitored
Telegram chat, assuming cron runs kiss-telegram --channel mychat --pairing every
minute:
Three properties make this loop robust:
- At-least-once replies. A reply is written to a persistent
delivery ledger before sending and removed only after a successful send. If the
process dies mid-send, the next tick re-sends it with a
(recovered reply)prefix — honest about the possibility of a duplicate. - Cursor discipline. The poll cursor is advanced only when the entire tick,
including all thread continuations, succeeded. A failed follow-up leaves both the cursor and
the thread’s
last_reply_tsuntouched, so it retries next tick and nothing is lost. - Circuit breaker. Five consecutive transport failures pause the channel for
15 minutes (
paused_untilin the state file), preventing a broken token from burning cron cycles and API quota.
3. The shared framework (5 infrastructure files)
__init__.py — package marker
Five lines: a docstring (“Channel integrations for KISS agents”) and nothing else. There are no re-exports; every consumer imports the private submodules directly.
_backend_utils.py (94 lines) — webhook-server helpers
ThreadedHTTPServer—ThreadingMixIn + HTTPServerwith daemon per-request threads and address reuse; used by every adapter that embeds an inbound HTTP receiver (WhatsApp, webhook, A2A, OpenAI-compat, DingTalk, Weixin, QQ, LINE, Zalo, Synology).drain_queue_messages(queue, limit, keep)— non-blocking drain of an in-memory message queue with an optional keep-predicate. Note the semantics: messages rejected by the predicate are consumed and dropped, not re-queued.stop_http_server(server, thread)— shutdown + close + 5 s thread join; returns(None, None)so callers can reset both attributes in one line.is_headless_environment()—KISS_HEADLESSenv override, then/.dockerenv, then Linux-without-DISPLAY detection. Used by the Gmail and Google Chat OAuth flows to decide whether to open a local browser.
_channel_agent_utils.py (1,804 lines) — the core
This is the heart of the package. Its pieces, in the order a message meets them:
Tool exposure — ToolMethodBackend
_NON_TOOL_METHODS = frozenset({
"connect", "find_channel", "find_user", "join_channel", "poll_messages",
"send_message", "send_typing", "is_from_bot", "strip_bot_mention",
"disconnect", "get_tool_methods", "poll_thread_messages",
})
def get_tool_methods(self):
return [getattr(self, name) for name in sorted(dir(self))
if not name.startswith("_")
and name not in _NON_TOOL_METHODS
and callable(getattr(self, name))]
Every public callable on a backend automatically becomes an LLM tool, except the twelve
protocol methods above. The mixin also provides safe defaults (no-op
join_channel/send_typing/disconnect, identity
find_channel/find_user/strip_bot_mention,
is_from_bot → False) so each adapter overrides only what its platform needs.
Credential persistence — ChannelConfig
Adapters built on ChannelConfig store their credentials as JSON at
~/.kiss/third_party_agents/<channel>/config.json, written with mode
0o600. (Slack instead keeps a per-workspace
slack/<workspace>/token.json; Gmail and Google Chat keep
token.json/credentials.json; Govee uses only an environment variable.)
The path property is resolved lazily: if the configured
directory lives under the default ~/.kiss, it is rebased onto $KISS_HOME
at access time, which is how the test suite isolates every process in its own temp home.
load() returns None when the file is missing, unparseable, or any
required key is empty — the universal “not authenticated” signal.
Per-channel poll state
{
"threads": {}, # thread_ts -> {chat_id, last_reply_ts, updated_at}
"ledger": [], # pending replies awaiting delivery
"failures": 0, # consecutive transport failures (circuit breaker)
"paused_until": 0.0, # breaker pause deadline (wall clock)
"approved_users": [], # DM-pairing approvals
"pending_pairing": {},# user_id -> {code, ts}
"cursor": "0", # backend poll cursor
"thread_rotation": 0 # fairness offset for continuations
}
State files are named
channel_state_<workspace>_<channel>_<sha256-10>.json and live next to
the adapter’s config.json when the module defines a module-level
ChannelConfig; adapters with custom credential stores (Slack, Google Chat) fall back
to $KISS_HOME/third_party_agents/channel_state/<agent-slug>/. The 10-hex-digit digest is computed over the raw
workspace/channel strings, so two channels that sanitize to the same filename can never share
state. Loading passes everything through a field-by-field normalizer that rejects NaN, infinities,
booleans-as-numbers and wrong types — a corrupt state file can never crash a tick. Saving is
atomic (mkstemp + os.replace). A sibling .lock file guards
each state file with flock: ticks take it non-blocking (an overlapping cron tick just
skips), while the pairing admin takes it blocking so an approval can never be overwritten by a
stale in-memory save.
The task carrier — BaseChannelAgent
Deliberately not an executable agent. It holds the channel identity —
tools_file (the adapter module’s own path, valid only if the module defines
get_tools()), workspace, an optional
channel_system_prompt appended to every prompt — and receives result write-backs
(last_run_result, budget_used, total_tokens_used,
total_steps). Its run() filters kwargs to the ten-name launch surface
(model_name, work_dir, max_budget, tools,
use_worktree, model_config, web_tools,
is_parallel, timeout, sock_path) and delegates to
run_agent_via_kiss_web.
The poll engine — ChannelRunner
One object per tick, constructed by channel_main in poll mode. Beyond the
lifecycle shown in section 2, notable mechanics:
- Already-answered detection. A message with
reply_count == 0is definitely unanswered; otherwise the runner polls the thread and checks whether any replyis_from_bot. This works only for backends that implementpoll_thread_messages(Slack is the main one). - DM pairing. With
--pairing, a message from an unapproved sender triggers a one-time code (secrets.token_hex(4)) sent back in-thread, together with the exact admin command to run, e.g.kiss-telegram --channel mychat --approve ab12cd34. A sender with a pending code is silently ignored (anti-spam).--approve/--list-pendingrun under the blocking state lock. - Thread continuations. For each remembered thread (up to 20 per tick, most
recently updated first, with a rotation offset for fairness; entries expire after 7 days),
follow-up user messages newer than
last_reply_tsare gathered, joined with blank lines, and sent to the same daemon chat (the storedchat_id), giving true multi-turn conversations. A pre-launch snapshot of the newest bot reply lets the runner suppress its automatic summary if the agent already answered in-thread itself. - Silence tokens. If the task’s summary is exactly
[SILENT]orNO_REPLY(after HTML-tag stripping), no reply is posted. The prompt context appended to every message tells the model about this escape hatch. - Typing indicators. Best-effort
send_typingbefore launching a task (implemented by Slack, Telegram, Discord, Matrix, Mattermost).
The CLI — channel_main + _channel_cli.py
channel_main(agent_cls, cli_name, channel_name=…, make_backend=…) is the single
entry point behind every kiss-* command. It has three paths:
- Pairing admin (
--approve CODE/--list-pending). - Poll mode (
--channel CH, only when the adapter passed amake_backendfactory): builds the backend, resolves--allow-usersentries throughbackend.find_user, applies per-channelchannel_model_name/channel_max_budgetconfig overrides (only when-m/-bwere genuinely omitted — the CLI parses them toNoneso omission is distinguishable from an explicit default), then runs oneChannelRunner.run_once()tick. - Interactive mode (default): runs one task (
-t "…"or-f file) with the adapter’s tools via the daemon and prints time/cost/tokens.
_channel_cli.py supplies the shared argparse builder
(-m -e --header -b -w --no-web -p/--no-parallel -t -f -V, abbreviations disabled), a
budget parser that rejects NaN/∞/≤0 (NaN would silently disable the budget guard since every NaN
comparison is false), and KISS_WORKDIR-aware working-directory resolution (the
installed wrappers run uv run --directory …, which chdirs away from the user’s
$PWD; the wrapper records the original directory in KISS_WORKDIR).
_kiss_web_launcher.py (317 lines) — the bridge to the daemon
run_agent_via_kiss_web(agent, prompt, …)— appends the agent’schannel_system_promptto the prompt (a launcher design choice —sorcar.runitself accepts a separatesystem_promptparameter, which the OpenAI-compat adapter uses), then callskiss.server.sorcar.run(…)with a 10-year default timeout, and copies the result (YAML{success, summary}, cost, tokens, steps, chat id) back onto the carrier agent.- Auto-started daemon. If no daemon socket is supplied, a process-global
in-process
RemoteAccessServer(the production daemon class) is started on a private Unix socket in a fresh temp dir, on a dedicated asyncio thread — so channel agents work even without an externally running kiss-web. - Workspace hand-off. The active workspace travels to the daemon-side
get_tools()via theKISS_CHANNEL_WORKSPACEenvironment variable, maintained with reference counting (not save/restore snapshots, which would restore each other out of order under concurrency); a warning is logged if two different workspaces are active at once, since the env var is process-global. KissWebChatAgent— a minimal carrier whose only job is to hold a daemonchat_idacross launches (resume_chat_by_id/new_chat), used by the runner to map conversation threads to persistent daemon chats.
4. Catalog of all 35 agents
Legend for the “Inbound” column: PULL polls the platform API on a cursor, PUSH runs an embedded webhook/event receiver, SERVER is itself an API server (request/response), OUT-ONLY can only send, STUB declares a poll method that always returns nothing.
| File | Service | Transport | Inbound | CLI | Poll mode |
|---|---|---|---|---|---|
slack_agent.py | Slack | slack_sdk WebClient (Web API) | PULL | kiss-slack | yes |
discord_agent.py | Discord | REST API v10 via requests | PULL | kiss-discord | yes |
msteams_agent.py | Microsoft Teams | Graph API via requests | PULL | kiss-msteams | yes |
googlechat_agent.py | Google Chat | googleapiclient (chat v1) | PULL | kiss-gchat | yes |
mattermost_agent.py | Mattermost | mattermostdriver (REST) | PULL | kiss-mattermost | yes |
matrix_agent.py | Matrix | matrix-nio AsyncClient on a background loop | PULL | kiss-matrix | yes |
irc_agent.py | IRC | raw TCP socket (optional TLS) | PUSH | kiss-irc | yes |
twitch_agent.py | Twitch | Helix REST via requests | STUB | kiss-twitch | no |
nextcloud_talk_agent.py | Nextcloud Talk | OCS API v4, HTTP Basic | PULL | kiss-nextcloud | yes |
synology_chat_agent.py | Synology Chat | webhooks both ways | PUSH :18083 | kiss-synology | yes |
tlon_agent.py | Urbit / Tlon | Eyre HTTP (scry/poke) | STUB | kiss-tlon | no backend passed |
whatsapp_agent.py | WhatsApp Business | Meta Graph API v21.0 | PUSH :18080 | kiss-whatsapp | no backend passed |
telegram_agent.py | Telegram | Bot API (python-telegram-bot + raw HTTP) | PULL | kiss-telegram | yes |
signal_agent.py | Signal | signal-cli subprocess | PULL | kiss-signal | yes |
simplex_agent.py | SimpleX Chat | simplex-chat CLI WebSocket | PUSH (WS events) | kiss-simplex | yes |
imessage_agent.py | iMessage (basic) | AppleScript via osascript | STUB | kiss-imessage | no |
bluebubbles_agent.py | iMessage (full) | BlueBubbles server REST | PULL | kiss-bluebubbles | yes |
sms_agent.py | SMS / MMS / voice | Twilio SDK | PULL | kiss-sms | yes |
phone_control_agent.py | Android phone | companion REST app on LAN | PULL (SMS) | kiss-phone | yes |
gmail_agent.py | Gmail | Gmail REST API, OAuth2 | PULL (is:unread) | kiss-gmail | no backend passed |
email_agent.py | Any IMAP/SMTP mailbox | pure stdlib imaplib/smtplib | PULL (UNSEEN) | kiss-email | yes |
dingtalk_agent.py | DingTalk robots | robot webhook + callback server | PUSH :18084 | kiss-dingtalk | yes |
feishu_agent.py | Feishu / Lark | lark_oapi SDK | PULL | kiss-feishu | yes |
wecom_agent.py | WeCom robots | group-robot webhook | OUT-ONLY | kiss-wecom | no |
weixin_agent.py | WeChat Official Account | customer-service API + XML callback | PUSH :18085 | kiss-weixin | yes |
qq_agent.py | QQ bot (official v2) | REST + Ed25519 webhook | PUSH :18086 | kiss-qq | yes |
line_agent.py | LINE | linebot v3 SDK + webhook | PUSH :18081 | kiss-line | yes |
zalo_agent.py | Zalo OA | Zalo OA REST + webhook | PUSH :18082 | kiss-zalo | yes |
webhook_agent.py | Generic inbound webhooks | embedded HTTP server, HMAC | PUSH :18090 | kiss-webhook | yes |
openai_compat_agent.py | OpenAI-style API | embedded API server | SERVER :18092 | kiss-oai (--serve) | no |
a2a_agent.py | Agent-to-Agent protocol | JSON-RPC 2.0 client + server | PUSH :18091 | kiss-a2a | yes |
ntfy_agent.py | ntfy.sh pub/sub | plain HTTP publish/poll | PULL | kiss-ntfy | yes |
nostr_agent.py | Nostr | pynostr over relay WebSockets | STUB | kiss-nostr | no |
homeassistant_agent.py | Home Assistant | REST API, long-lived token | OUT-ONLY | kiss-ha | disabled |
govee.py | Govee smart lights | Govee cloud API, stdlib urllib | OUT-ONLY | standalone script | n/a |
“no backend passed” means the adapter's main() does not hand a make_backend
factory to channel_main, so the --channel poll mode is not reachable
from that CLI. WhatsApp and Gmail nonetheless have working poll_messages
implementations; Tlon's queue-draining poll is effectively a stub, since nothing ever feeds its
event queue. Twitch, Nostr and iMessage have outright stub poll methods; WeCom, Home Assistant and OpenAI-compat are inbound-incapable or request/response by
design.
5. Team-chat agents
Slack (slack_agent.py, 1,050 lines)
The richest adapter. Auth is a bot token (xoxb-…) stored per workspace at
~/.kiss/third_party_agents/slack/<workspace>/token.json, with automatic
migration of the old flat path and multi-workspace CLI flags
(--workspace, --list-workspaces, --delete-workspace).
Transport is the synchronous Slack Web API through slack_sdk.WebClient with SDK
retries disabled (retry_handlers=[]); there is no Socket Mode or Events API —
incoming messages are found by polling conversations_history /
conversations_replies with a watermark that bumps the last-seen timestamp by one
microsecond (Slack timestamps have 6-decimal resolution). The two poll methods have their own
3-attempt exponential backoff on OSError (SSL handshake timeouts).
Tools: 15 Web-API tools (post/update/delete message, read history and threads,
list channels/users, user & channel info, create channel, invite, reactions, topic, file
upload, and search_messages — which requires a user token, so it always fails with a
bot token) plus 4 auth tools including start_slack_browser_auth(), which instructs
the model to create a Slack app in the browser autonomously. Tools return JSON strings; the
large list/read tools slice their output at 8,000 characters.
Filtering: is_from_bot drops anything with a bot_id
or from the bot’s own user id; strip_bot_mention removes
<@BOT_ID>. Slack is the only backend implementing
poll_thread_messages, which unlocks the runner’s “already answered” detection and
in-thread continuations.
Discord (discord_agent.py)
Talks to the Discord REST API v10 directly with requests — no discord.py. Auth is
a bot token (Authorization: Bot …). On the first poll of a channel it synthesizes a
snowflake ID for “one second ago” (((now_ms − 1420070400000) << 22), the Discord
epoch is in the top bits) so history is not replayed. The runner-facing send_message
posts with raise_on_error=True so failed sends land in the delivery ledger and retry;
tool-facing methods instead return the error body as JSON. 11 tools (guilds, channels, messages,
reactions, thread creation, members, invites) + 4 auth tools including
start_discord_browser_auth().
Microsoft Teams (msteams_agent.py)
Microsoft Graph API with Azure AD client-credentials OAuth2; the token is cached and
refreshed 60 s before a hard-coded 3,600 s lifetime (the real expires_in is
ignored). Poll mode uses a composite channel id "team_id:channel_id" and a
lastModifiedDateTime gt … OData filter as cursor — which means edited messages are
re-delivered. Replies are posted as HTML, threaded via the /replies endpoint.
Self-echo suppression works only if an optional bot_id is configured. Note that the
/me/* endpoints used by list_teams/list_chats generally
require delegated (user) auth, so with app-only credentials Graph returns an error object that the
tools pass through as data.
Google Chat (googlechat_agent.py)
Official googleapiclient (“chat”, “v1”), authenticating with either a service
account (service_account.json) or an OAuth2 installed-app flow
(headless-aware: prints the URL instead of opening a browser when
is_headless_environment()). Like Gmail, its custom (non-ChannelConfig)
credential store still honors $KISS_HOME — Slack's custom token path, by contrast,
does not; every ChannelConfig-based adapter honors it automatically.
9 space/member/message CRUD tools with
pagination; poll cursor is a createTime > … filter; replies are threaded via
thread.name. clear_googlechat_auth deletes only token.json,
keeping the client secrets.
Mattermost (mattermost_agent.py)
Uses the mattermostdriver REST client with a personal access token; the typing
indicator bypasses the driver with a direct POST /api/v4/users/me/typing. Polling is
get_posts_for_channel(since=ms); after each batch the backend bumps its
process-local starting point by +1 ms, but the cursor it returns (and that the
runner persists) is the unincremented last-post timestamp.
Every polled post gets thread_ts = root_id or its own id, so replies always thread.
Tool methods guard with assert self._driver is not None — calling them before auth
raises rather than returning a JSON error.
Matrix (matrix_agent.py)
Built on matrix-nio’s AsyncClient, driven from synchronous code through one
persistent background event loop (nio caches its aiohttp session on the loop of the first request,
so all coroutines must share a single long-lived loop). Auth is homeserver URL + access token,
validated with whoami(). Polling is a sync(since=next_batch) call
extracting RoomMessageText events; the sync token is the cursor (in-memory only — a
fresh process re-syncs from scratch). The module-level _raise_on_send_error() exists
because nio reports send failures by returning error responses rather than raising; the
delivery ledger would silently drop those replies otherwise. Typing indicators use a raw HTTP PUT
to the client-server API. 10 room/user tools.
IRC (irc_agent.py)
A raw TCP socket client (optionally TLS via ssl.create_default_context()) — the
module docstring’s mention of “the irc library” is stale; no such import exists. A daemon reader
thread parses lines, answers PING with PONG, and queues
PRIVMSGs into an in-memory queue that poll_messages drains (filtered by
target; the cursor is inert). _send_raw reconnects on demand from the persisted
config, because a fresh backend built by get_tools() inside the daemon starts
disconnected. Tools cover JOIN/PART, PRIVMSG/NOTICE, TOPIC, KICK, WHOIS and NickServ
identification; WHOIS/TOPIC responses arrive asynchronously and are not captured
(fire-and-forget). Quirk: connect_irc accepts a realname parameter but
never uses it.
Twitch (twitch_agent.py)
Helix REST API only (the docstring’s “twitchio for chat” is stale — chat messages are sent
through Helix POST /chat/messages). poll_messages is a stub, so the
adapter is send-only in channel mode and main() passes no backend factory. 9 tools
(streams, channel/user info, chatters, bans, clips, channel search). A subtle correctness detail
works in its favor: tools build results as json.dumps({"ok": True, **result}), and
because the HTTP helpers stamp ok: False into failed results, the later
**result unpacking overwrites the optimistic True.
Nextcloud Talk (nextcloud_talk_agent.py)
Nextcloud Talk OCS API v4 with HTTP Basic auth (username + app password). The poll cursor is
the numeric Talk message id, which doubles as the replyTo target when the
runner posts a threaded reply. Polling deliberately fetches the latest page
(lookIntoFuture=0) and filters client-side by id, because passing
lastKnownMessageId would page backwards. 8 room/message tools;
authenticate_nextcloud validates by listing rooms before saving.
Synology Chat (synology_chat_agent.py)
Webhooks in both directions: sends by POSTing Synology’s form-encoded
payload=<json> convention to an incoming-webhook URL (serialized under a send
lock), and receives on an embedded HTTP server at 0.0.0.0:18083 that parses
outgoing-webhook posts, optionally checks a shared token field (accepts everything
when unset), queues messages, and always answers 200 so Synology never disables the webhook.
Only webhook_url is required; authentication saves without any network validation.
Urbit / Tlon (tlon_agent.py)
Drives an Urbit ship through the Eyre HTTP server with a cookie-holding
requests.Session: login at /~/login with the dojo +code,
reads via scries (GET /~/scry/{app}{path}.json), writes via pokes (JSON arrays PUT to
/~/channel/{uid}). Tools: list groups/channels, read channel posts, post a message
(a channel-action poke with an inline memo), profile, plus raw
poke/scry. The _event_queue/_sse_thread
attributes are unused scaffolding — no SSE subscription exists, so nothing ever feeds the poll
queue, and main() accordingly passes no backend factory. The ship name is optional at
auth time but required for pokes; send_message parses
group/name/channel ids and silently no-ops on fewer than three parts.
6. Personal-messaging agents
WhatsApp (whatsapp_agent.py, 953 lines)
Uses the WhatsApp Business Cloud API (Meta Graph API v21.0) over HTTPS — not a
device bridge. Credentials are an access token + phone-number ID from the Meta developer console,
validated against the Graph API before saving. Incoming messages arrive by push: an
embedded webhook server on port 18080 answers Meta’s hub.challenge GET handshake
(verified only when a verify_token is configured) and queues POSTed messages, which
poll_messages drains filtered by sender phone. Non-text messages become
"[image message]"-style placeholders. 14 tools: text, template (needed to initiate
conversations outside the 24-hour service window), media, reactions, location, interactive
messages, contacts, mark-as-read, business-profile get/update, media upload/URL/delete, and
template listing (requires the optional waba_id).
The POST webhook endpoint performs no X-Hub-Signature-256
verification, and with no verify_token the GET handshake is open — anyone who can
reach port 18080 can inject messages. Deployments should firewall the port or front it with a
verifying proxy.
Telegram (telegram_agent.py)
Telegram Bot API with a @BotFather token. Hybrid transport: the python-telegram-bot
synchronous Bot object serves most tools, while poll_messages and typing
indicators speak raw HTTP so cursors persist and tests can target a local server. The
getUpdates cursor contract is carefully engineered: the persisted cursor and the
process-local _last_update_id + 1 are merged with max() so polling is
monotonic; the returned cursor is highest_update_id + 1, which also confirms
(deletes) consumed updates server-side on the next poll. Non-text updates are skipped but still
consumed by the cursor. poll_messages never raises — any failure returns
([], oldest). 15 tools (send text/photo/document/poll, edit/delete/pin/forward,
chat & member info, ban/unban, raw get_updates).
Signal (signal_agent.py)
Shells out to the external signal-cli binary
(signal-cli -u NUMBER … via subprocess.run). Registration/verification
must be done outside the agent; the config stores only the phone number and binary path. Receiving
uses receive --output=json, which is destructive: messages fetched once do
not reappear, so anything filtered out by channel or beyond the limit is lost. Send failures are
detected from the exit code or the substring “error” in stderr. 5 tools (send message/attachment,
raw receive, list contacts/groups). is_from_bot compares the sender to the agent’s
own number.
SimpleX Chat (simplex_agent.py)
Connects to a user-launched simplex-chat CLI’s WebSocket
(simplex-chat -p 5225 → ws://127.0.0.1:5225) with the synchronous
websockets client. Commands are {"corrId", "cmd"} frames; responses are
matched by corrId under an I/O lock while unsolicited newChatItems events (only
received directions, directRcv/groupRcv) are queued for polling — so no
inbound message is lost during a command round-trip. Handles both the newer
Right/Left-wrapped response envelope and the older direct one. Sends use
the CLI’s @'name' text contact syntax (group sends via #name are not
wired). 3 tools: send, list contacts, get-or-create contact address (with
/address → /show_address fallback).
iMessage two ways (imessage_agent.py and bluebubbles_agent.py)
imessage_agent is the zero-setup route: macOS-only AppleScript through
osascript driving Messages.app. Sending (message or attachment) is fully implemented,
with AppleScript-injection escaping and an iMessage/SMS service whitelist; receiving is a stub —
poll_messages returns nothing, and get_messages returns an explanatory
note pointing at BlueBubbles. bluebubbles_agent is the full-featured route
through a BlueBubbles server’s REST API: real cursor polling
(POST /api/v1/message/query with a millisecond after cursor from
dateCreated), self-message filtering via isFromMe, and sends using the
server’s private API. The server password travels as a query parameter on every request.
SMS / voice via Twilio (sms_agent.py)
The Twilio SDK (imported lazily) provides SMS, MMS, WhatsApp-via-Twilio, and voice calls.
Polling lists inbound messages (to=own_number) with a date_sent
timestamp cursor; on the very first poll (cursor 0) all recent history up to the limit passes the
filter. 11 tools. Quirks: list_messages accepts a page_token parameter
that is never used, and tools assert on the client, so calling them before
authentication raises instead of returning a JSON error.
Android phone (phone_control_agent.py)
Controls an Android phone through a companion REST app on the phone’s LAN address
(default port 8080) — not ADB. Optional API key as an X-API-Key header. 10 tools:
SMS send/read, conversations, call make/end/log, device info, notification list/dismiss/reply
(the reply tool drives apps like WhatsApp or Signal through their notifications). Poll mode treats
inbound SMS as the message stream with a timestamp cursor; when watching a single sender the
cursor still advances past other senders’ messages, permanently skipping them for that channel.
The runner-facing send_message is fire-and-forget (ignores HTTP errors), while the
send_sms tool reports status.
7. Email agents
Gmail (gmail_agent.py, ~1,000 lines)
The official Gmail REST API through googleapiclient with OAuth2 at the full
https://mail.google.com/ scope. The user token lives at
$KISS_HOME/third_party_agents/gmail/token.json (chmod 0600, auto-refreshed), the
OAuth client secrets at credentials.json; the OAuth flow runs a local server and, in
headless environments, prints the URL instead of opening a browser. A dedicated
start_gmail_browser_setup() tool walks the model through creating the OAuth client in
Google Cloud Console autonomously with its browser tools.
Tools (14 + 4 auth): profile, search/list (full Gmail query syntax), get
message with plain-text body extraction and attachment metadata, send, reply (sets
In-Reply-To/References and reuses the thread id; reply_all
merges To+Cc), drafts, trash/untrash/permanent delete, label list/create/modify (archive = remove
INBOX, mark read = remove UNREAD), attachment download, whole-thread fetch. Bodies are truncated
to 4,000 chars, lists to 8,000.
Channel mode: find_channel maps a label name to its id;
poll_messages queries is:unread in the label/INBOX — deduplication rides
entirely on the unread flag (the cursor is passed through unchanged). For replies, the recipient
and subject are resolved from the newest message of the Gmail thread, raising
ValueError when nothing resolves. Note main() passes no backend factory,
so the --channel poll mode is not wired for Gmail’s CLI.
Generic IMAP/SMTP (email_agent.py)
Pure standard library: IMAP4_SSL for reading, SMTP_SSL or
STARTTLS for sending. The design goal is crash-safe, at-least-once mail handling: polling searches
UNSEEN and fetches with BODY.PEEK[], which never marks mail read — mail
is only marked read explicitly via the mark_email_read tool, so nothing is lost if
the process dies mid-task. An automation filter drops non-human mail: senders matching
noreply/no-reply/donotreply/mailer-daemon, any Auto-Submitted header
other than “no”, Precedence: bulk/junk/list, or a List-Id header.
Replies are threaded through an in-memory cache mapping Message-ID → {sender, subject}
(preferring Reply-To); after a restart, a threaded reply whose channel id is a
mailbox name cannot resolve a recipient and send_message raises — deliberately, to
avoid mailing a bogus recipient. Threading headers are only set when thread_ts looks
like a real Message-ID (contains “@”). 4 tools: send_email,
list_unread_emails (includes automated mail but labels it),
read_email, mark_email_read.
8. Asian-platform agents
Seven adapters cover the major Chinese, Japanese-adjacent and Vietnamese platforms. Five run embedded webhook receivers on dedicated ports; their inbound-security postures span the full spectrum:
| Agent | Outbound | Inbound port | Inbound verification |
|---|---|---|---|
QQ (qq_agent.py) | v2 REST, Authorization: QQBot token | 18086 | Mandatory Ed25519 signature on every POST, including the op-13 URL-validation challenge; key seed = secret repeated to 32 bytes |
DingTalk (dingtalk_agent.py) | robot webhook, optional per-request HMAC-SHA256 URL signing | 18084 | Optional HMAC-SHA256 (outgoing_token) + 1-hour timestamp-skew window, constant-time compare; open when unset |
WeChat OA (weixin_agent.py) | customer-service API, cached cgi-bin/token access token (expiry − 60 s, lock-protected) | 18085 | SHA-1 query-signature: always on the GET handshake, on POST only when callback_token set; plus DTD/XXE defusing (drops payloads containing <!DOCTYPE/<!ENTITY) and strict Content-Length/1 MiB caps |
LINE (line_agent.py) | official linebot v3 SDK (push, reply, profile, quota, image) | 18081 (fixed) | None — channel_secret is persisted but X-Line-Signature is never checked |
Zalo OA (zalo_agent.py) | OA REST, token in an access_token header | 18082 (fixed) | None — only user_send_text events handled, no signature check |
Feishu/Lark (feishu_agent.py) | official lark_oapi SDK | — (pull-polls im.v1.message.list) | n/a |
WeCom (wecom_agent.py) | group-robot webhook | — (outbound-only) | n/a — inbound would need the enterprise AES envelope, explicitly out of scope |
Other notable details. Feishu is the only one of the seven that pull-polls the
platform API, filtering client-side on create_time > cursor; because it passes no
start time to the API, messages older than the newest page can be missed on a busy chat.
QQ tracks group openids seen in webhook events in an in-memory set to route
send_message between the group and C2C endpoints (unknown ids default to C2C; the
set is lost on restart). Weixin and QQ share the same access-token caching pattern
(lock, expiry = now + expires_in − 60 s). Zalo has the strictest auth flow of the
seven — authenticate_zalo live-validates against get_oa_info() before
persisting. SDK-based agents (Feishu, LINE) defer imports so a missing package degrades to
“unauthenticated” instead of crashing the module. None of these platforms support threads;
thread_ts is uniformly ignored.
9. Machine-to-machine agents
Generic webhooks (webhook_agent.py)
An embedded HTTP server accepting POST /hook/<route> from external systems
(GitHub, CI, monitoring). Each named route has its own secret and one of two HMAC-SHA256 schemes:
GitHub’s X-Hub-Signature-256, or a generic timestamped
X-Kiss-Signature/X-Kiss-Timestamp pair with a 300 s skew limit —
both verified with constant-time comparison. The request pipeline is strictly ordered, each stage
with its own HTTP status:
404 unknown route → 411/400/413 body checks (1 MB cap) → 401 bad signature
→ duplicate-delivery suppression (LRU of 256 ids, with an in-flight reservation
so a concurrent retry cannot double-fire)
→ 429 rate limit (60 events/min/route) → 400 invalid JSON (retryable)
→ dot-path filters (mismatch: dropped with 200, recorded as delivered)
→ template render ({payload} = compact JSON, {a.b.c} = dot-path lookup)
→ 200 queued for the agent — or — 202 delivered through another
channel module (route’s deliver_module/deliver_channel), 502 on failure (retryable)
Routes are managed with three tools (add_webhook_route,
remove_webhook_route, list_webhook_routes — the last redacts secrets).
Replies are inbound-only: the agent’s answer is re-routed via the route’s delivery channel when
configured, otherwise logged and dropped. The server binds 0.0.0.0, so security rests entirely on
the per-route secrets — and the signature check runs before rate limiting, so
unauthenticated floods cannot consume the rate budget.
OpenAI-compatible API server (openai_compat_agent.py)
Turns kiss-web into an OpenAI-style backend so any OpenAI frontend (Open WebUI, LibreChat, the
openai SDK) becomes a chat surface for Sorcar. kiss-oai --serve runs an
embedded server (default 127.0.0.1:18092) with two endpoints: an unauthenticated
GET /v1/models advertising a single “kiss-sorcar” model, and a bearer-authenticated
POST /v1/chat/completions (constant-time key compare, 5 MB body cap). The last
user message becomes a daemon task; system/developer messages become the system prompt.
Because OpenAI clients are stateless, conversations are re-linked to persistent daemon chats by
hashing the canonicalized conversation prefix (sha256 of role/content pairs) into
chat_map.json (atomic writes, 5,000-entry LRU). After each run the chat id is stored
under two keys — the request’s messages, and the messages plus the assistant’s reply —
so the client’s next request (which appends both) maps back to the same chat. Streaming is
technically supported but fake: one SSE chunk carrying the whole reply, then [DONE].
Its single backend tool, openai_compat_status(), reports the configured host/port
and whether the server is actually bound (probing the TCP port when another process runs it).
This is the only adapter whose send_message raises unconditionally (replies are HTTP
responses); like Home Assistant, it explicitly disables poll mode with
make_backend=None.
Agent-to-Agent protocol (a2a_agent.py)
Bidirectional A2A. Outbound, three tools: a2a_discover (fetches the
peer’s agent card from /.well-known/agent-card.json, with the legacy
agent.json fallback), a2a_call (JSON-RPC 2.0 message/send),
and a2a_get_task (tasks/get polling). Inbound, an embedded
server publishes this agent’s card and accepts the same two methods: a peer’s message is queued
as untrusted text with a task record in state “submitted”; when the channel runner’s reply comes
back, send_message completes exactly the right task (matched by
thread_ts = task id) and attaches the reply as an artifact the peer collects by
polling.
Security: the server refuses to bind a non-loopback host without a bearer token; a sliding
20-turns-per-contextId-per-hour cap breaks agent-to-agent ping-pong loops; every inbound
JSON-RPC POST is appended to a JSONL audit log (card GETs are not); bodies are capped at 1 MB; JSON-RPC error codes are used with
full discipline (−32700 … −32001). Two acknowledged weaknesses: the task/turn state is in-memory
and untrimmed, and the inbound token check is a plain != rather than a constant-time
compare.
ntfy (ntfy_agent.py)
Publish/subscribe on an ntfy topic (default server https://ntfy.sh): publish is a
plain-text POST with optional title/priority/click headers; reading is
GET {server}/{topic}/json?poll=1&since=… parsing newline-delimited JSON. Loop
prevention is by echo tag: everything the agent publishes carries the tag
kiss-sorcar, and is_from_bot treats any message with that tag as its
own. An optional bearer token supports self-hosted servers; on public ntfy.sh the only protection
is topic-name secrecy. When a notification has a title, it is prepended to the text so consumers
that only read text still see it.
Nostr (nostr_agent.py)
The decentralized Nostr protocol via pynostr; identity is a private key
(nsec… or hex) stored in plaintext config, with a configurable relay list (default
wss://relay.damus.io). Each publish creates a fresh RelayManager, signs,
runs synchronously, sleeps one second, and closes. Tools: kind-1 notes, NIP-10 replies, NIP-04
encrypted DMs, kind-0 profile metadata, and relay list management. Receiving is a stub, and the
runner-facing send_message discards the error JSON from publish_note —
one of the few adapters that break the “raise on failed send” delivery-ledger contract (see
section 12), so a failed publish is silently treated as delivered.
10. Smart-home & device agents
Home Assistant (homeassistant_agent.py)
The Home Assistant REST API with a long-lived access token. Explicitly outbound-only: there is
no meaningful inbound stream in the plain REST API, so poll mode is disabled
(make_backend=None) and the runner-facing send_message creates a
persistent notification instead. 6 tools: ha_get_states,
ha_call_service (e.g. domain “light”, service “turn_on”),
ha_list_services, ha_get_history, ha_render_template,
ha_fire_event. Uniquely, every user-supplied URL segment passes a
path-traversal guard (rejecting /, \, ..) on top of
quote(…, safe=""), and a custom system prompt teaches the model the
inspect-then-act workflow.
Govee lights (govee.py)
The outlier of the directory: a 166-line standalone, executable, stdlib-only CLI for the Govee
Developer cloud API — no channel framework, no LLM tools, no config file. Auth is the
GOVEE_API_KEY environment variable. Commands: list,
state, on, off, brightness 1..100,
color <hexRGB>, kelvin <K>. Devices are found by exact MAC,
exact name, or unique case-insensitive substring, with a hard-coded exclusion list
(“permanent outdoor lights”, “string lights”). Agents use it by shelling out; per the user’s
SORCAR.md, this is the designated way to act on home lights. There is no HTTP error handling —
failures raise a traceback.
11. Cross-cutting themes
Where everything lives
| Item | Location |
|---|---|
| Adapter credentials | ~/.kiss/third_party_agents/<channel>/config.json (mode 0600) for ChannelConfig-based adapters; Slack uses slack/<workspace>/token.json; Gmail and Google Chat use token.json/credentials.json in their directories |
| Per-channel poll state | channel_state_<ws>_<ch>_<sha10>.json next to the adapter’s config, or under channel_state/<agent-slug>/ for custom-config adapters (plus a .lock flock file) |
| Default poll-mode work dir | ~/.kiss/channel_work |
KISS_HOME | rebases config/state paths (honored by ChannelConfig, Gmail, Google Chat) |
KISS_WORKDIR | original $PWD recorded by the installed CLI wrappers |
KISS_HEADLESS | forces headless OAuth behavior |
KISS_CHANNEL_WORKSPACE | workspace hand-off to the daemon-side get_tools() (refcounted) |
| Webhook ports | 18080 WhatsApp · 18081 LINE · 18082 Zalo · 18083 Synology · 18084 DingTalk · 18085 Weixin · 18086 QQ · 18090 webhook · 18091 A2A · 18092 OpenAI-compat |
The delivery-ledger contract
The runner treats a send_message that returns without raising as a successful
delivery and removes the reply from its at-least-once ledger. Most adapters honor this — Discord
posts with raise_on_error=True, Matrix converts nio’s returned error responses into
exceptions, and email/BlueBubbles/Twitch/Feishu/QQ/Zalo/Nextcloud/Synology raise on API failure
(Gmail raises ValueError for unresolvable recipients and lets Google API errors
propagate). At least four do not fully honor it: Nostr discards the error JSON
entirely; Phone Control’s runner-facing send is fire-and-forget;
MS Teams never raises on HTTP errors, so a Graph 4xx/5xx JSON response looks
successful; and the generic webhook’s deliver-through-another-channel path
ignores the delivery function’s failure boolean. For those, a failed reply can be silently
treated as delivered.
Security posture at a glance
- Secrets are plaintext JSON under
~/.kiss(chmod 0600) — bot tokens, IRC passwords, even Nostr private keys. The file mode is the protection boundary. - Constant-time comparisons guard the webhook HMAC schemes, the DingTalk and Weixin callback signatures, and the OpenAI-compat bearer key; A2A’s inbound token check and Synology’s token match are plain equality.
- Unverified inbound endpoints: WhatsApp POSTs (no X-Hub-Signature check), LINE (secret stored but unused), Zalo, and Synology-without-token. All bind 0.0.0.0, so network-level protection matters for these.
- Hardening highlights: QQ’s mandatory Ed25519 verification, Weixin’s DTD/XXE defusing, webhook’s ordered 404→413→401→dedup→429 pipeline, Home Assistant’s path-traversal guards, A2A’s loopback-or-token rule and turn cap, the AppleScript escaping + service whitelist in the iMessage agent.
Quirks worth knowing
- The great rename. Tool names like
list_third_party_agents(Slack, Discord, Teams, Mattermost, Tlon) andsearch_third_party_agents(Twitch) actually list channels; a repo-wide mechanical rename of the word “channels” → “third_party_agents” hit method names, parameters (upload_file(third_party_agents=…)), result keys and docstrings. Behavior is unaffected, but the names are misleading. - Stale docstrings. IRC claims to use “the irc library” (it is a raw socket client); Twitch claims “twitchio for chat” (it is Helix REST).
- Truncated JSON. The widespread
json.dumps(...)[:8000]truncation on most (not all) list-returning tools can cut output mid-JSON on large results; pagination cursors are the designed mitigation. - Dropped-on-drain semantics. Queue-backed backends (IRC, webhook, A2A, DingTalk, Synology, LINE, Zalo, WhatsApp, SimpleX, QQ, Weixin) discard drained messages that fail the channel filter rather than re-queueing them.
- Assert-guarded tools. Mattermost, Twilio SMS, Feishu, Google Chat, Gmail,
LINE, Slack and Telegram tools
asserton their client object, so calling them directly before authenticating raisesAssertionErrorinstead of returning the usual{"ok": false}JSON (in practice_get_tools()withholds backend tools while unauthenticated).
Summary
The package is a textbook adapter architecture: one 1,800-line core
(_channel_agent_utils.py) supplies configuration, state, reliability machinery
(atomic state, flock ticks, delivery ledger, circuit breaker, thread continuations, DM pairing)
and the CLI; each of the 34 platform files then contributes only what is platform-specific —
a transport, a handful of tools, and three auth closures. The kiss-web daemon does all actual AI
work; adapters never run models themselves. The design trades sophistication for uniformity so
consistently that a new platform integration is essentially: subclass two classes, write
poll_messages/send_message, and add one get_tools()
function.