Third-Party Channel Agents: How the Parts Work Together

An architecture walk-through of src/kiss/agents/third_party_agents/ — backends, carriers, the module-as-tools-file contract, the in-process kiss-web daemon, and the three ways channel agents run.

KISS Sorcar · module report · 23 platform integrations · single execution funnel: kiss.server.sorcar.run()

1What lives in this directory

The third_party_agents package connects KISS Sorcar to 23 external messaging and communication platforms: Slack, Gmail, Discord, Telegram, WhatsApp, IRC, Matrix, Signal, SMS, iMessage, BlueBubbles, Google Chat, MS Teams, Mattermost, Feishu, LINE, Zalo, Twitch, Nostr, Tlon, Nextcloud Talk, Synology Chat, and phone control (plus one outlier, govee.py, a standalone smart-light CLI).

The most important design fact — the result of a recent refactor — is that none of these classes is an executable agent. Every task is submitted to the kiss-web daemon through the public API kiss.server.sorcar.run(), and the daemon builds and runs its own chat agent. The channel classes are thin carriers of three things:

Why carriers? The launcher never executed the passed instance anyway — the daemon has always built its own agent. Dropping the old SorcarAgent inheritance removed an entire parallel execution path (and ~700 lines of redundant code) while keeping one uniform behavior: everything runs on the daemon, with live web streaming, follow-ups, stop support, and chat persistence for free.

2The big picture — one execution funnel

Whatever entry point you use (interactive CLI, poll mode, cron poller, or a direct agent.run(...) call in Python), the flow converges on a single function, run_agent_via_kiss_web(), which passes the agent's OWN module file as the API's tools= path and submits the prompt to an in-process kiss-web daemon over a private Unix socket. The daemon imports that module and calls its top-level get_tools(), which builds a fresh agent from the credentials persisted under ~/.kiss.

your process (CLI / poller / Python) in-process kiss-web daemon (same OS process) XAgent(BaseChannelAgent) — carrier XChannelBackend authenticated client ~15 tool methods 4 auth tools check / authenticate / clear / browser-setup run_agent_via_kiss_web(agent, prompt, ...) appends channel_system_prompt to the prompt; passes chat_id if agent is a KissWebChatAgent agent.tools_file — the agent's OWN module x_agent.py defines a top-level get_tools() that builds a fresh agent from persisted ~/.kiss credentials kiss.server.sorcar.run(prompt, tools=path, chat_id=...) RemoteAccessServer private Unix-domain socket, mode 0600, started lazily by _ensure_api_server() daemon-built chat agent standard tools bash, file editing, browser, web research channel tools from get_tools() of the imported agent module Platform API Slack Web API / Gmail REST / Discord / Matrix / ... kiss-web lifecycle live webview streaming · follow-ups · stop · chat persistence agent.tools_file + workspace tools=path of the agent module unix socket builds & runs tool calls hit the fresh authenticated backend results write-back
Fig. 1 — The single execution funnel. The carrier never runs the task; the daemon-built agent does, using channel tools it builds itself by importing the agent module and calling its get_tools(). Results (YAML summary, cost, tokens, steps, chat id) are written back onto the carrier.
Key subtlety: the daemon-side tools are built on a fresh agent instance, not the caller's. State is shared through persistence, not memory: authentication tools save tokens under ~/.kiss, so the fresh instance authenticates identically. Because nothing lives in process memory, the daemon no longer has to share the caller's process — the launcher keeps the in-process daemon only so channel agents work without an externally started kiss-web. The active workspace travels through the KISS_CHANNEL_WORKSPACE environment variable, set by the launcher for the duration of the task.

3The two-class template every platform follows

All 23 modules are stamped from the same template: an API-wrapper backend plus a thin agent subclass. Learning one platform means learning them all.

XChannelBackend(ToolMethodBackend) wraps the platform SDK / REST API infrastructure methods — framework-only connect · disconnect · find_channel · find_user join_channel · poll_messages · poll_thread_messages send_message · is_from_bot · strip_bot_mention listed in _NON_TOOL_METHODS → never exposed to the LLM; ToolMethodBackend provides no-op / identity defaults tool methods — auto-discovered LLM tools every other public method (~15/platform): list channels · read history · post/update/delete · react · upload files · search · user info ... convention: return JSON string, ≤ 8 KB, errors as {"ok": false, "error": ...} — never raised at the model XAgent(BaseChannelAgent) thin carrier — no execution logic self._backend = XChannelBackend(...) self.channel_system_prompt = "..." two overridden hooks _is_authenticated() — validate stored credential _get_auth_tools() — always the same 4-tool pattern: check_x_auth · authenticate_x(credential) clear_x_auth · start_x_browser_auth browser-auth returns instructions for autonomous provisioning credentials — ChannelConfig ~/.kiss/third_party_agents/<service>/[workspace/]config.json chmod 600 · path resolved lazily against $KISS_HOME
Fig. 2 — The uniform two-class template. Backends split into framework-only infrastructure methods and auto-discovered LLM tool methods; agents add credentials and the 4-tool auth pattern.

Backend: ToolMethodBackend and automatic tool discovery

get_tool_methods() reflects over the backend and returns every public method that is not in the _NON_TOOL_METHODS frozenset. Adding a new LLM tool to a platform is therefore just adding a public method — no registration code. The infrastructure methods (connect, poll_messages, send_message, …) form the protocol that framework code — ChannelRunner and channel_main() — calls; the LLM never sees them.

Agent: the 4-tool auth pattern

4The launch path, step by step

BaseChannelAgent.run(prompt_template, **kwargs) filters its kwargs through the LAUNCH_KWARG_NAMES allowlist (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(), which performs the sequence below.

caller / carrier launcher agent module (tools file) daemon + agent 1. agent.run(prompt, **filtered kwargs) 2. _ensure_api_server() lazily starts in-process RemoteAccessServer 3. tools_path = agent.tools_file (the agent's own module) 4. env KISS_CHANNEL_WORKSPACE = agent.workspace 5. prompt += channel_system_prompt; chat_id if KissWebChatAgent 6. sorcar.run(prompt, tools=tools_path, chat_id=..., ...) 7. imports the module, calls get_tools() 8. fresh agent's auth + backend tools 9. builds & runs its agent 10. YAML {success, summary} + cost/tokens/steps + chat_id 11. write-back: last_run_result, budget_used, ... 12. env var restored in finally
Fig. 3 — Sequence of one channel-agent task. The daemon runs the task and builds the channel tools itself from the agent module's get_tools(); the caller's carrier only names the module and receives results. The workspace env var is restored when the task ends.

5The tools-file contract — how the daemon builds authenticated tools

The public API sorcar.run() accepts extra tools only as a file path to a Python module whose top-level get_tools() returns the tool callables. For channel agents that file is simply the agent's own module — no bridge, registry, wrapper, or generated file exists:

  1. Every x_agent.py defines a module-level get_tools() that instantiates a fresh XAgent() and returns agent._get_tools() (the 4 auth tools, plus the backend tool methods when the stored credential authenticates). Slack's variant reads the KISS_CHANNEL_WORKSPACE environment variable to pick the workspace.
  2. The launcher passes agent.tools_file — the path of the module defining the agent's class — as sorcar.run(tools=...). The daemon compiles and executes the module's current source and calls its get_tools(); the returned callables become agent tools as-is, with their original names, signatures, and docstrings.
  3. Credentials are the shared state: authentication tools persist tokens under ~/.kiss/third_party_agents/<service>/, so the fresh daemon-side agent authenticates exactly like the caller's carrier. A token saved by authenticate_x(...) during one task is picked up by the next task's fresh get_tools().
# every agent module ends with (slack shown; others take no workspace)
def get_tools() -> list:
    """Return the Slack channel tools (kiss.server.sorcar.run tools-file contract)."""
    workspace = os.environ.get("KISS_CHANNEL_WORKSPACE", "default") or "default"
    return SlackAgent(workspace=workspace)._get_tools()
This contract is why passing an agent object (not just a prompt string) to the launcher is meaningful: the instance names the module whose get_tools() the daemon calls, carries the workspace, and receives the results — while the daemon-built agent talks to the platform through an identically-authenticated fresh backend.

6Three ways channel agents run

ModeEntry pointWho drives itTypical use
Interactive CLI kiss-slack -t "Send 'Hello!' to #general" → each module's main() is a one-liner: channel_main(SlackAgent, "kiss-slack", ...) channel_main() instantiates the agent (forwarding --workspace if the constructor accepts it), builds run kwargs via _channel_cli.py, launches, prints run stats One-off tasks with live webview streaming
One-shot poll mode kiss-slack --channel CH [--allow-users u1,u2] ChannelRunner.run_once() using the backend's infrastructure methods (Fig. 4) Answer pending channel messages once (e.g. from cron)
Cron pollers (Slack only) slack_sorcar_poller.py (DMs), slack_channel_sorcar_poller.py (default #sorcar) Poller scripts with their own slack_sdk client, using KissWebChatAgent for chat identity Slack as a persistent chat UI: each thread = a resumable Sorcar conversation

7Inside ChannelRunner (poll mode)

ChannelRunner is generic — it works for all 23 platforms because it speaks only the infrastructure-method protocol. channel_main() builds it from the module's _make_backend() factory and resolves --allow-users names via find_user() first.

connect() find_channel(name) join_channel(id) poll_messages(id, "0", limit=50) for each message: skip filters · is_from_bot(msg) — bot's own messages · sender not in --allow-users (if given) · _has_bot_reply() via poll_thread_messages() pending message _handle_message() · strip_bot_mention(text) + channel/thread context → prompt · build a plain BaseChannelAgent carrier inline · tools = the agent module's path (its get_tools() runs in the daemon) · launch a fresh daemon task via run_agent_via_kiss_web() if no bot reply appeared in the thread: post the task summary send_message(channel_id, reply_text, thread_ts) threaded reply · one retry on send failure disconnect() in finally block All boxes on this figure are infrastructure methods (framework-only) except the blue _handle_message step, which launches a daemon task whose LLM sees the agent module's auth + backend tools.
Fig. 4 — ChannelRunner.run_once(): connect → join → poll → filter → one daemon task per pending message → threaded reply → disconnect.

8Slack cron pollers — threads as persistent chats

Two sibling scripts turn Slack into a persistent front-end for Sorcar. Unlike ChannelRunner they keep conversation identity: they use KissWebChatAgent, the one carrier subclass that adds a chat-id surface (chat_id, new_chat(), resume_chat_by_id()).

Slack channel #sorcar ksen: "profile the test suite" ts = 1723.001 (new thread) └ bot: result summary (mrkdwn) └ ksen: "now fix the slowest one" └ bot: result summary ksen: "unrelated new request" ts = 1723.777 (new thread) state.json (atomic) thread_ts → chat_id map min_ts watermark ~/.kiss/slack_channel_ sorcar_poller/state.json no message answered twice; empty results not posted → next tick retries daemon chats chat A turn 1: profile suite turn 2: fix slowest resumed via chat_id chat B fresh new_chat() new_chat() reply in thread → resume_chat_by_id(chat A) new top-level message → new chat
Fig. 5 — Poller mapping: each top-level Slack message spawns a fresh daemon chat; replies in that thread resume the same chat, making every Slack thread a multi-turn Sorcar conversation.

Reliability details

9Shared plumbing and file map

FileRole
_channel_agent_utils.pyThe core: ToolMethodBackend (+ _NON_TOOL_METHODS and tool discovery), BaseChannelAgent (carrier: _get_tools(), allowlist-filtered run(), result attributes), ChannelConfig (chmod-600 JSON credentials under ~/.kiss/third_party_agents/<service>/, lazily rebased on $KISS_HOME), ChannelRunner, channel_main(), and the JSON-config helpers.
_kiss_web_launcher.py_ensure_api_server() (lazy in-process daemon on a private 0600 Unix socket, dedicated asyncio thread), KissWebChatAgent (chat-id carrier), run_agent_via_kiss_web() (the funnel of Fig. 3).
_channel_cli.pyShared argparse builder (-m model, -b budget, -w workdir, -t task / -f file, -e endpoint), budget parsing, launch work-dir resolution, run-stats printing.
_backend_utils.pyHelpers for webhook-style backends: ThreadedHTTPServer, drain_queue_messages(), stop_http_server(), is_headless_environment().
<platform>_agent.py × 23One module per platform, each stamped from the Fig. 2 template: XChannelBackend + XAgent + _make_backend() factory + one-liner main()channel_main(XAgent, "kiss-x").
slack_sorcar_poller.py, slack_channel_sorcar_poller.pyCron pollers (Section 8). They use their own slack_sdk.WebClient helpers rather than SlackChannelBackend.
govee.pyThe outlier: standalone Govee smart-light CLI (list/on/off/brightness/color/kelvin via the Govee Developer API, keyed by $GOVEE_API_KEY) — not a channel agent.

Conventions that hold everywhere

10Summary

Each of the 23 modules is a ToolMethodBackend API wrapper whose public methods auto-become LLM tools, plus a BaseChannelAgent carrier with four auth tools and a channel_system_prompt, plus a module-level get_tools() that rebuilds those tools from persisted credentials. Nothing executes locally: run_agent_via_kiss_web() passes the agent module's own path as the tools= file of kiss.server.sorcar.run() and submits every task to an in-process kiss-web daemon. The daemon's own agent — standard tools plus the channel tools from the module's get_tools() — does the work, and results plus chat identity are written back onto the carrier. A uniform CLI (channel_main), a generic poll loop (ChannelRunner), and the Slack cron pollers layer interactive tasks, one-shot message answering, and persistent thread↔chat conversations on top of the same funnel.