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.

Generated 2026-08-16 from a full read of every file in the directory.

Contents

  1. The big picture
  2. Life of a message (end to end)
  3. The shared framework (5 infrastructure files)
  4. Catalog of all 35 agents
  5. Team-chat agents
  6. Personal-messaging agents
  7. Email agents
  8. Asian-platform agents
  9. Machine-to-machine agents
  10. Smart-home & device agents
  11. 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:

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.

Outside services Slack / Discord / Teams… WhatsApp / Telegram / SMS… Gmail / IMAP mailboxes DingTalk / Feishu / QQ / LINE… Webhooks / A2A peers / ntfy Home Assistant / Android Channel adapter (one per service) *ChannelBackend transport + LLM tools (ToolMethodBackend) *Agent (BaseChannelAgent) auth tools + identity carrier ChannelRunner (shared) poll → filter → task → reply, state + retries channel_main (shared CLI) kiss-slack, kiss-gmail, kiss-whatsapp… get_tools() (module-level) tools-file contract for the daemon config.json under ~/.kiss/third_party_agents/ kiss-web daemon kiss.server.sorcar.run public API over Unix-domain socket Sorcar chat agent bash + files + browser + channel tools chat persistence + live events thread ↔ chat-id continuity auto-started in-process if no daemon running poll / webhook replies task summary
Every adapter follows the same shape. Incoming platform traffic is turned into daemon tasks; the daemon’s answer is posted back to the platform as a threaded reply.

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:

  1. *ChannelBackend — a subclass of ToolMethodBackend that 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.
  2. *Agent — a subclass of BaseChannelAgent that 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.
  3. main() — one line delegating to the shared channel_main(AgentClass, "kiss-<name>", …) CLI entry point.
  4. get_tools() — a module-level function returning AgentClass()._get_tools(). This is the tools-file contract: when a channel task runs, the adapter’s own .py file path is passed to the daemon, which imports it and calls get_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:

cron / CLI ChannelRunner Backend / platform kiss-web daemon 1. run_once(): take flock; skip if breaker-paused 2. connect() + find/join channel 3. redeliver pending ledger replies “(recovered reply)” 4. poll_messages(cursor, limit=50) 5. filter: drop own messages, unapproved senders → pairing code, already-answered threads skipped 6. sorcar.run(text + channel context, chat_id of thread, tools=adapter.py) 7. daemon imports adapter file, calls get_tools(), runs the agent with bash/files/browser + channel tools 8. YAML result {success, summary}; chat id stored for the thread 9. suppress reply if summary is [SILENT]/NO_REPLY or the agent already posted in the thread 10. ledger-backed threaded reply (retry once) 11. advance cursor, reset breaker, save state atomically
One poll “tick”. Steps 5–10 repeat per pending message; a separate continuation pass (up to 20 threads per tick, rotated for fairness) resumes conversations where the user replied inside an already-answered thread.

Three properties make this loop robust:

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

_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:

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:

  1. Pairing admin (--approve CODE / --list-pending).
  2. Poll mode (--channel CH, only when the adapter passed a make_backend factory): builds the backend, resolves --allow-users entries through backend.find_user, applies per-channel channel_model_name/channel_max_budget config overrides (only when -m/-b were genuinely omitted — the CLI parses them to None so omission is distinguishable from an explicit default), then runs one ChannelRunner.run_once() tick.
  3. 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

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.

FileServiceTransportInboundCLIPoll mode
slack_agent.pySlackslack_sdk WebClient (Web API)PULLkiss-slackyes
discord_agent.pyDiscordREST API v10 via requestsPULLkiss-discordyes
msteams_agent.pyMicrosoft TeamsGraph API via requestsPULLkiss-msteamsyes
googlechat_agent.pyGoogle Chatgoogleapiclient (chat v1)PULLkiss-gchatyes
mattermost_agent.pyMattermostmattermostdriver (REST)PULLkiss-mattermostyes
matrix_agent.pyMatrixmatrix-nio AsyncClient on a background loopPULLkiss-matrixyes
irc_agent.pyIRCraw TCP socket (optional TLS)PUSHkiss-ircyes
twitch_agent.pyTwitchHelix REST via requestsSTUBkiss-twitchno
nextcloud_talk_agent.pyNextcloud TalkOCS API v4, HTTP BasicPULLkiss-nextcloudyes
synology_chat_agent.pySynology Chatwebhooks both waysPUSH :18083kiss-synologyyes
tlon_agent.pyUrbit / TlonEyre HTTP (scry/poke)STUBkiss-tlonno backend passed
whatsapp_agent.pyWhatsApp BusinessMeta Graph API v21.0PUSH :18080kiss-whatsappno backend passed
telegram_agent.pyTelegramBot API (python-telegram-bot + raw HTTP)PULLkiss-telegramyes
signal_agent.pySignalsignal-cli subprocessPULLkiss-signalyes
simplex_agent.pySimpleX Chatsimplex-chat CLI WebSocketPUSH (WS events)kiss-simplexyes
imessage_agent.pyiMessage (basic)AppleScript via osascriptSTUBkiss-imessageno
bluebubbles_agent.pyiMessage (full)BlueBubbles server RESTPULLkiss-bluebubblesyes
sms_agent.pySMS / MMS / voiceTwilio SDKPULLkiss-smsyes
phone_control_agent.pyAndroid phonecompanion REST app on LANPULL (SMS)kiss-phoneyes
gmail_agent.pyGmailGmail REST API, OAuth2PULL (is:unread)kiss-gmailno backend passed
email_agent.pyAny IMAP/SMTP mailboxpure stdlib imaplib/smtplibPULL (UNSEEN)kiss-emailyes
dingtalk_agent.pyDingTalk robotsrobot webhook + callback serverPUSH :18084kiss-dingtalkyes
feishu_agent.pyFeishu / Larklark_oapi SDKPULLkiss-feishuyes
wecom_agent.pyWeCom robotsgroup-robot webhookOUT-ONLYkiss-wecomno
weixin_agent.pyWeChat Official Accountcustomer-service API + XML callbackPUSH :18085kiss-weixinyes
qq_agent.pyQQ bot (official v2)REST + Ed25519 webhookPUSH :18086kiss-qqyes
line_agent.pyLINElinebot v3 SDK + webhookPUSH :18081kiss-lineyes
zalo_agent.pyZalo OAZalo OA REST + webhookPUSH :18082kiss-zaloyes
webhook_agent.pyGeneric inbound webhooksembedded HTTP server, HMACPUSH :18090kiss-webhookyes
openai_compat_agent.pyOpenAI-style APIembedded API serverSERVER :18092kiss-oai (--serve)no
a2a_agent.pyAgent-to-Agent protocolJSON-RPC 2.0 client + serverPUSH :18091kiss-a2ayes
ntfy_agent.pyntfy.sh pub/subplain HTTP publish/pollPULLkiss-ntfyyes
nostr_agent.pyNostrpynostr over relay WebSocketsSTUBkiss-nostrno
homeassistant_agent.pyHome AssistantREST API, long-lived tokenOUT-ONLYkiss-hadisabled
govee.pyGovee smart lightsGovee cloud API, stdlib urllibOUT-ONLYstandalone scriptn/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 5225ws://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:

AgentOutboundInbound portInbound verification
QQ (qq_agent.py)v2 REST, Authorization: QQBot token18086Mandatory 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 signing18084Optional 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)18085SHA-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)Nonechannel_secret is persisted but X-Line-Signature is never checked
Zalo OA (zalo_agent.py)OA REST, token in an access_token header18082 (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

ItemLocation
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 statechannel_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_HOMErebases config/state paths (honored by ChannelConfig, Gmail, Google Chat)
KISS_WORKDIRoriginal $PWD recorded by the installed CLI wrappers
KISS_HEADLESSforces headless OAuth behavior
KISS_CHANNEL_WORKSPACEworkspace hand-off to the daemon-side get_tools() (refcounted)
Webhook ports18080 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

Quirks worth knowing

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.

Sources: full reads of all 40 files in src/kiss/agents/third_party_agents/ (August 2026 tree). Line counts are from wc -l at analysis time.