Skip to content

feat(kiloclaw): kilo-chat channel plugin#2361

Open
iscekic wants to merge 72 commits intomainfrom
feat/kiloclaw-kilo-chat-plugin
Open

feat(kiloclaw): kilo-chat channel plugin#2361
iscekic wants to merge 72 commits intomainfrom
feat/kiloclaw-kilo-chat-plugin

Conversation

@iscekic
Copy link
Copy Markdown
Contributor

@iscekic iscekic commented Apr 13, 2026

Summary

Adds the kilo-chat channel: a kiloclaw OpenClaw plugin and the external chat backend service it talks to, with Telegram-style live-edit streaming and message reactions.

kilo-chat plugin (services/kiloclaw/plugins/kilo-chat/)

OpenClaw channel plugin for kiloclaw that talks to the kilo-chat service.

Outbound (five actions via controller proxy):

  • POST {KILOCHAT_BASE_URL}/v1/messages — create a message; returns { messageId, version }.
  • PATCH {KILOCHAT_BASE_URL}/v1/messages/:id — edit with monotonic version; 409 is treated as a benign drop.
  • DELETE {KILOCHAT_BASE_URL}/v1/messages/:id — preview cleanup on dispatch failure.
  • POST {KILOCHAT_BASE_URL}/v1/conversations/:id/typing — typing indicator (server holds ~5s, plugin re-pings every 3s).
  • POST {KILOCHAT_BASE_URL}/v1/messages/:id/reactions / DELETE … — add/remove reactions, wired through the OpenClaw react message-tool action.

All share the same auth hop: plugin → controller bearer OPENCLAW_GATEWAY_TOKEN, controller re-auths with Bearer KILOCHAT_API_TOKEN + x-kilo-sandbox-id on the upstream leg.

Preview streaming: Inbound dispatch instantiates a per-conversation PreviewStream. First onPartialReply token POSTs; subsequent partials coalesce into one PATCH per 500ms window. finalize flushes pending edits, then performs one final PATCH. Dispatch failures abort and DELETE the in-flight message. 409 drops no longer clobber lastSentText, so a subsequent flush/finalize re-sends.

Reactions: Plugin exposes a react action through the SDK's ChannelMessagingAdapter.actions hook. handleKiloChatReactAction resolves conversationId/messageId from tool params or toolContext, normalises emoji shortcodes via node-emoji (with GitHub/Slack aliases), and calls the controller add/remove endpoints. resolveExecutionMode is local so the tool runs inline in the agent turn.

Outbound target parsing: messaging adapter implements normalizeTarget / parseExplicitTarget / resolveOutboundSessionRoute so kilo-chat:<conversationId> (ULID) works as an explicit send target.

Inbound webhook: kilo-chat service delivers webhooks via CF service binding RPC → kiloclaw worker's deliverChatWebhook method → resolves target Fly machine → forwards to controller → OpenClaw SDK dispatch with typing keepalive. Routing supports both legacy base64url(userId) sandbox IDs and new ki_{uuid} instance-keyed sandbox IDs (via @kilocode/worker-utils/instance-id). No HMAC needed — the service binding is a trusted internal call.

Message format: Uses content blocks ([{ type: "text", text: "..." }]) for extensibility.

kilo-chat service (services/kilo-chat/)

Cloudflare Worker chat backend at chat.kiloapps.io.

Architecture:

  • Durable Objects: ConversationDO (per-conversation state — messages, reactions, members, SSE fan-out, typing) and MembershipDO (per-user conversation index with denormalized recency)
  • Queue + Service Binding: One webhook per bot member enqueued to CF Queue; queue handler calls kiloclaw via service binding RPC (max 3 retries)
  • Auth: Kilo JWTs for human users, API keys with x-kilo-sandbox-id for bot/service callers
  • Real-time: SSE streaming with Last-Event-ID replay (messages + reactions interleaved), 30s keepalive pings via DO alarms
  • Storage: Drizzle + DO SQLite with enforced FKs and CHECK constraints; reactions keyed by (message_id, member_id, emoji) with soft-delete + monotonic event IDs for replay

API surface:

Method Path Description
POST /v1/conversations Create conversation (user + bot for sandbox)
GET /v1/conversations List my conversations
GET /v1/conversations/:id Get conversation details
POST /v1/messages Create message
GET /v1/conversations/:id/messages List messages (cursor paginated, reactions aggregated)
PATCH /v1/messages/:id Edit message (optimistic versioning)
DELETE /v1/messages/:id Soft-delete message
POST /v1/messages/:id/reactions Add reaction
DELETE /v1/messages/:id/reactions Remove reaction
GET /v1/conversations/:id/events SSE stream (messages + reactions, replayable)
POST /v1/conversations/:id/typing Typing indicator

Data model: ULID message IDs (monotonic via ulid package), JSON content blocks, server-controlled versioning for edits (409 on stale), soft deletes, reactions with added_at / removed_id pair enforced by CHECK constraint. Supports multi-party conversations (data model ready, currently 1:1 user + bot).

Wiring

buildEnvVars passes KILOCHAT_API_TOKEN (encrypted) + KILOCHAT_BASE_URL (plain) + KILOCHAT_REACTION_LEVEL (plain); secret catalog registers KILOCHAT_API_TOKEN; config-writer enables channels['kilo-chat'] and plugins.entries['kilo-chat'] when both KILOCHAT_API_TOKEN and KILOCHAT_BASE_URL are set, and threads reactionLevel through; Dockerfile builds + installs @kiloclaw/kilo-chat at /usr/local/lib/node_modules/@kiloclaw/kilo-chat; CI content-hash includes plugins/kilo-chat/.

Kiloclaw worker converted to WorkerEntrypoint class to support RPC. kilo-chat service binds to kiloclaw via services binding in wrangler.jsonc.

Test plan

  • pnpm test in services/kiloclaw — all pass
  • pnpm test in services/kiloclaw/plugins/kilo-chat — 67 tests (PreviewStream, client, react-action, channel adapter, webhook)
  • pnpm test in services/kilo-chat — 88 tests (DO unit, reactions, routes, SSE replay, webhook delivery)
  • pnpm typecheck in both services — clean
  • pnpm lint in both services — 0 errors
  • Local e2e: script creates conversation, sends message, kiloclaw receives webhook via service binding, LLM responds, bot message arrives via SSE, agent calls react tool, reaction.added event fires and is visible in the aggregated GET (see plugins/kilo-chat/LOCAL_E2E.md)
  • Production e2e (deferred — needs deployment)

@iscekic iscekic self-assigned this Apr 13, 2026
@kilo-code-bot
Copy link
Copy Markdown
Contributor

kilo-code-bot bot commented Apr 13, 2026

Code Review Summary

Status: 1 Issue Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 0

Fix these issues in Kilo Cloud

Issue Details (click to expand)

WARNING

File Line Issue
services/kiloclaw/plugins/kilo-chat/src/webhook.ts 62 sentAt is only checked for non-empty string content, so malformed timestamps flow into Date.parse() as NaN.
Other Observations (not in diff)

None.

Files Reviewed (17 files)
  • packages/kiloclaw-secret-catalog/src/__tests__/catalog.test.ts - 0 issues
  • packages/kiloclaw-secret-catalog/src/catalog.ts - 0 issues
  • services/kiloclaw/controller/src/config-writer.test.ts - 0 issues
  • services/kiloclaw/controller/src/config-writer.ts - 0 issues
  • services/kiloclaw/controller/src/routes/kilo-chat.ts - 0 issues
  • services/kiloclaw/plugins/kilo-chat/.gitignore - 0 issues
  • services/kiloclaw/plugins/kilo-chat/README.md - 0 issues
  • services/kiloclaw/plugins/kilo-chat/openclaw.plugin.json - 0 issues
  • services/kiloclaw/plugins/kilo-chat/src/channel.test.ts - 0 issues
  • services/kiloclaw/plugins/kilo-chat/src/channel.ts - 0 issues
  • services/kiloclaw/plugins/kilo-chat/src/client.test.ts - 0 issues
  • services/kiloclaw/plugins/kilo-chat/src/client.ts - 0 issues
  • services/kiloclaw/plugins/kilo-chat/src/preview-stream.test.ts - 0 issues
  • services/kiloclaw/plugins/kilo-chat/src/preview-stream.ts - 0 issues
  • services/kiloclaw/plugins/kilo-chat/src/webhook.test.ts - 0 issues
  • services/kiloclaw/plugins/kilo-chat/src/webhook.ts - 1 issue
  • services/kiloclaw/src/routes/kiloclaw.test.ts - 0 issues

Reviewed by gpt-5.4-20260305 · 3,344,263 tokens

iscekic added 18 commits April 13, 2026 15:27
…base cast

Extract makeClient() helper to eliminate repeated KiloChatClient construction
across the three outbound handlers. Narrow the outbound.base cast from `as never`
to `as { deliveryMode: 'direct'; attachedResults: unknown }` so deliveryMode
remains type-checked. Add FRAGILE comment documenting the SDK-spread dependency.
iscekic added 4 commits April 14, 2026 17:51
* feat(kilo-chat): scaffold service with wrangler, vitest, drizzle

* feat(kilo-chat): add ULID generation utility

* feat(kilo-chat): add SSE formatting helpers

* feat(kilo-chat): add Drizzle schemas for ConversationDO and MembershipDO

* feat(kilo-chat): add dual auth middleware (JWT + API key)

* feat(kilo-chat): add MembershipDO with conversation index

Per-user/bot Durable Object storing a conversation membership list with
CRUD ops (add, list, update lastMessageId, remove). Includes vitest tests
and updated wrangler types.

* feat(kilo-chat): add ConversationDO with message CRUD

* feat(kilo-chat): add conversation routes (create, list, get)

Implements POST /v1/conversations, GET /v1/conversations, and GET /v1/conversations/:id behind auth middleware, with full integration test coverage.

* feat(kilo-chat): add message routes (create, list, edit, delete)

Implements POST /v1/messages, GET /v1/conversations/:id/messages,
PATCH /v1/messages/:id, and DELETE /v1/messages/:id with membership
checks, webhook queue enqueue for bot members, and MembershipDO
lastMessageId updates.

* feat(kilo-chat): add SSE events endpoint with fan-out and replay

- Add in-memory SSE client tracking and broadcast() to ConversationDO
- Broadcast message.created, message.updated, message.deleted events after each DB write
- Add fetch() handler on ConversationDO to handle /subscribe with member auth
- Support Last-Event-ID replay by querying messages with id > lastEventId
- Add alarm() keepalive that pings all connected clients every 30s
- Add /v1/conversations/:id/events route that forwards to DO's fetch handler
- Tests: access control (403/404), broadcast no-crash, streaming header checks, replay verification
- Streaming tests placed last in file to work around miniflare SQLite WAL isolated storage limitation

* feat(kilo-chat): add typing indicator endpoint with SSE broadcast

* feat(kilo-chat): add webhook queue delivery with HMAC signing

Implements the WEBHOOK_QUEUE consumer that delivers HMAC-SHA256 signed
payloads to the kiloclaw webhook endpoint, with per-message ack/retry
and graceful handling when secrets are not configured.

* fix(kilo-chat): resolve all oxlint errors

* refactor(kilo-chat): replace hand-rolled ULID with ulid package, use monotonicFactory for message IDs

* fix(kilo-chat): review fixes — webhook payload, timing-safe auth, SSE replay, writer cleanup

- Fix webhook queue message shape to match WebhookMessage type (was sending wrong fields)
- Use constant-time comparison for API key auth via crypto.subtle.timingSafeEqual
- SSE replay always sends message.created for missed messages (client never saw them)
- Close dead SSE writers on disconnect to prevent resource leaks
- Remove redundant callerKind guard in message creation

* fix(kilo-chat): review round 2 — timing-safe fix, server-controlled versioning, single webhook

- Remove redundant string-length check in timingSafeEqual (keep byte-length only)
- Server now controls message version (increments from current), not client-supplied
- Send one webhook per message (not one per bot member) since payloads are identical
- Allow version 0 in edit schema for stale clients

* fix(kilo-chat): harden webhook error path against body read failure

* refactor(kilo-chat): rename KILOCHAT_API_KEY to KILOCHAT_API_TOKEN to match kiloclaw

* feat(kilo-chat): add chat.kiloapps.io custom domain route

* fix(kilo-chat): bind NEXTAUTH_SECRET to NEXTAUTH_SECRET_PROD in secrets store

* fix(kiloclaw/kilo-chat): update plugin to send content blocks instead of flat text

Replace `text: string` with `content: ContentBlock[]` in CreateMessageParams and
EditMessageParams, wrap text strings in `[{ type: 'text', text }]` blocks at all
call sites (preview-stream and webhook deliver), and update all three test files
to match. Also change the e2e script SANDBOX_ID default to 'e2e-test-sandbox'.

* fix(kilo-chat): add zod validation at all input boundaries
iscekic added 2 commits April 14, 2026 18:42
Convert kiloclaw worker export to WorkerEntrypoint class and add
deliverChatWebhook RPC method. kilo-chat now calls kiloclaw directly
via service binding instead of external HTTP, eliminating the need for
HMAC signing/verification and the KILOCHAT_WEBHOOK_SECRET entirely.

- Convert kiloclaw default export from plain object to WorkerEntrypoint
- Add deliverChatWebhook RPC: resolves instance from targetBotId, forwards to Fly machine
- Enqueue one webhook per bot member (future-proofs multi-bot conversations)
- Remove HMAC signing from kilo-chat and verification from plugin
- Delete KILOCHAT_WEBHOOK_SECRET from all services, types, and config
Hardcode allowed origins (kilo.ai, app.kilo.ai, localhost:3000) with
optional ALLOWED_ORIGINS env var override. Applied to all /v1/* routes.
Also fix vitest config to properly stub the kiloclaw service binding.
iscekic added 19 commits April 14, 2026 20:24
getBotMembersExcluding returns { id, kind } objects, not strings.
The queue payload needs the string id, not the full object.
…erEntrypoint

The RPC refactor (4b223d9) removed KILOCHAT_WEBHOOK_SECRET and converted
the worker default export to WorkerEntrypoint, but left three test-only
leftovers that broke CI:

- catalog.test.ts: asserted the removed secret was still in
  INTERNAL_SENSITIVE_ENV_VARS.
- config-writer.test.ts: stubbed the removed secret in env fixture.
- index.test.ts: cloudflare:workers mock did not export WorkerEntrypoint,
  so importing src/index.ts threw at module load.
Introduce the reactions table with message/member foreign keys and the
invariant CHECK on deleted_at/removed_id. Tighten the existing tables
with FKs (messages.sender_id, messages.in_reply_to_message_id,
conversation.created_by) and CHECKs (members.kind, messages.deleted,
messages.version). Pre-deploy: migration is regenerated from scratch.

Also catch constraint violations in initialize() and createMessage()
to return {ok: false} results instead of throwing — prevents isolated
storage corruption in the CF vitest pool. Swap insert order in
initialize() so members are created before conversation (FK).
Commit fdee92c changed ConversationDO.initialize() from void to a
result type so that constraint violations no longer poison the DO
storage. The route forgot to check the result — constraint failures
during init were silently swallowed, creating broken conversations.
Also drop a redundant/mislabeled schema-constraint test.
Implements the single-table write rules: new inserts, idempotent
re-adds on live rows, soft-delete on remove, and re-activation with
a fresh id on re-add.
…ests

Shift AddReactionResult and RemoveReactionResult to an ok-primary
discriminated union matching CreateMessageResult. Lets consumers use
a single narrowing pattern (`if (!result.ok) ...`) without mixing
`ok in result` and `added in result` checks. Also tightens the FK
rejection tests to assert the error branch explicitly.
listMessages now returns reactions grouped by emoji with count and
memberIds per message. Dead (removed) reactions are excluded. Uses
the partial index reactions_by_message_live so aggregation is an
O(live rows) scan.
Extends the /subscribe replay path to include reaction.added and
reaction.removed events alongside messages, ordered by ULID. Uses
UNION ALL to guarantee index usage on both the id and removed_id
columns. For re-add cycles, replay is lossy — only the final add
event survives in storage.
The SSE replay tests in Task 4 triggered workerd TransformStream
unhandled rejections that were masked by globally enabling
dangerouslyIgnoreUnhandledErrors. That flag suppresses unhandled
rejections across the whole suite and would hide real bugs in
unrelated tests.

Root cause: the collectReplay helper used Promise.race(reader.read(),
timeout) in a loop. When the timeout won (after all replay data was
consumed), a pending reader.read() promise was left dangling. The
subsequent reader.cancel() caused that orphaned read to reject inside
workerd's RPC/TransformStream plumbing as an unhandled "Stream was
cancelled" error that cannot be caught from JS.

Fix: rewrite collectReplay to read the single pre-buffered replay
chunk directly (no race), then cancel the stream while no read() is
pending. This mirrors how the existing events-routes.test.ts replay
test works — it reads until the expected content appears, breaking
the loop before cancelling, so there is never a dangling read promise.
The live broadcast path includes `at` on reaction.added, but the
/subscribe replay was dropping it. Spec requires it on replay too.
Select added_at in the UNION ALL and emit it in the SSE payload.
Adds the HTTP surface for reactions. Emoji validation enforces 1-64
UTF-8 bytes and rejects C0/C1 control chars. Member-of-conversation
gate mirrors the existing /v1/messages routes.
POST and DELETE /_kilo/kilo-chat/messages/:id/reactions proxy to the
kilo-chat service with the sandbox-scoped API token. Mirrors the
existing /send, /messages edit/delete, /typing proxy pattern.
Extend KiloChatClient with HTTP wrappers that hit the new controller
reaction proxy routes. addReaction accepts both 201 (fresh) and 200
(dedupe) and returns { id }; removeReaction expects 204.
Implements handleAction for the shared message tool with emoji
shortcode normalization via node-emoji. Routes add/remove through
the existing KiloChatClient to the controller proxy. Registered in
channel.ts alongside the existing sendText outbound.
Replace Object.assign + as-any cast with a straightforward spread,
matching the WhatsApp reference pattern. Drop an unrequested
capabilities declaration that was added outside the plan's scope.
…in handleAction

Replaces a hand-rolled inline type in channel.ts with the canonical
SDK type. Future SDK changes to the context shape will surface at
compile time rather than silently diverging.
Maps KILOCHAT_REACTION_LEVEL env to config.channels['kilo-chat'].reactionLevel.
Defaults to 'minimal' when unset or invalid. OpenClaw reads this value
to guide agent reaction use; kiloclaw is pure passthrough.
iscekic added 2 commits April 15, 2026 16:28
Two pre-existing gaps made the reactions feature untestable end-to-end
through the real kilo-chat → kiloclaw → container pipeline.

1. deliverChatWebhook couldn't route to instance-keyed sandboxes.

   The RPC entrypoint in services/kiloclaw/src/index.ts assumed the
   legacy sandboxId format (base64url-encoded userId) and decoded the
   incoming `bot:kiloclaw:<sandboxId>` target back to a userId for
   registry lookup. For the current `ki_{uuid-no-hyphens}` instance-
   keyed format this yielded Unicode garbage, so the registry lookup
   always missed and webhooks silently dropped after 4 retries.

   Detect the `ki_` prefix and resolve the DO directly by instanceId
   (which is the doKey). Legacy format keeps the existing
   registry+Postgres+userId-fallback chain.

2. Plugin had no messaging adapter.

   With no `messaging` block, OpenClaw's outbound target resolver had
   no way to parse `kilo-chat:<conversationId>` targets, so the
   agent's `message` tool (including `action: "react"`) failed with
   `Unknown target ...` before our handleAction even ran.

   Add a minimal messaging adapter: normalize/parse the optional
   `kilo-chat:` prefix, recognize ULID conversationIds, and build a
   direct outbound session route. Mirrors the pattern used by
   qa-channel / nostr.

Verified end-to-end locally: user message → kilo-chat queue →
env.KILOCLAW.deliverChatWebhook → docker-local container →
agent replies and reacts → reaction.added arrives on SSE and
aggregates correctly in GET /v1/conversations/:id/messages.
…rom empty emoji

When 'emoji' was missing and 'remove' was not provided, the action silently
routed into the delete branch and threw 'remove requires a specific emoji',
making a forgotten emoji indistinguishable from an explicit remove request.

Now 'remove' defaults to false when absent, and missing/empty emoji on the
add path throws 'emoji is required'.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant