Skip to content

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

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

feat(kiloclaw): kilo-chat channel plugin#2361
iscekic wants to merge 53 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.

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

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

Outbound (four 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).

All four 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.

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. 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, 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, 30s keepalive pings via DO alarms

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)
PATCH /v1/messages/:id Edit message (optimistic versioning)
DELETE /v1/messages/:id Soft-delete message
GET /v1/conversations/:id/events SSE stream
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. Supports multi-party conversations (data model ready, currently 1:1 user + bot).

Wiring

buildEnvVars passes KILOCHAT_API_TOKEN (encrypted) + KILOCHAT_BASE_URL (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; 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 — 43 tests (PreviewStream, routes, streaming, typing)
  • pnpm test in services/kilo-chat — 67 tests (DO unit, route integration, 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, LLM responds, bot message arrives via SSE
  • 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 12 commits April 13, 2026 18:06
Kilo Chat is operator-provisioned, not user-configurable. Exposing it in
the user-facing secret catalog (which drives the channel-config UI
alongside Telegram/Discord/Slack) was wrong: users never bring their own
KILOCHAT_API_TOKEN or KILOCHAT_WEBHOOK_SECRET.

Move the two secret env vars into INTERNAL_SENSITIVE_ENV_VARS so the
sensitive-keys classifier in buildEnvVars still encrypts them, and add a
KILOCHAT_ prefix to DENIED_ENV_VAR_PREFIXES so users can't shadow them
via custom secrets. Revert the catalog-test additions and drop the UI
catalog entry entirely.
File was added in this PR; the alias to KiloChatRouteOptions had no
prior consumers to preserve.
The client is new; nothing to preserve compatibility with. sendText was
a one-line wrapper around createMessage that dropped the version field
to match OpenClaw's outbound adapter return shape. Destructure in the
adapter callsite instead and delete the alias + its tests.
pnpm-lock.yaml is only generated at the workspace root in pnpm
workspaces, so there's no per-package lockfile to ignore. The sibling
kiloclaw-customizer plugin has no .gitignore either.
These handlers lived under `outbound.base.attachedResults` with a
narrow cast because the SDK's `ChannelOutboundAdapter` declares no
editText/deleteMessage fields. They reached the runtime adapter only
because the SDK's `resolveChatChannelOutbound` spreads `outbound.base`
verbatim — a JS spread side-effect, not an API. They were also unused:
PreviewStream invokes KiloChatClient.editMessage/deleteMessage directly.

Edit-in-place streaming is officially a plugin-side concern (confirmed
against openclaw/src/channels/plugins/outbound.types.ts and Telegram's
reference implementation in extensions/telegram/src/). The onPartialReply
callback plus plugin-owned client is the canonical pattern, which we
already use via webhook.ts and preview-stream.ts.
Partial-edit streaming is the only intended behavior for this channel,
so stop pretending `mode: off | block` exist. Drop readStreamingConfig,
the streamingMode branch in buildDeliverWiring, the streamingMode/
throttleMs fields on ResolvedKiloChatAccount, and the associated tests.
Throttle stays a module-level constant (500ms) — not user-tunable.
Streaming mode/throttleMs aren't user-tunable; the plugin always streams
with a fixed 500ms coalesce window. Keep enabled/baseUrl/dmPolicy/
allowFrom since config-writer still seeds those and OpenClaw validates
channels[pluginId] with additionalProperties: false.
Drop the Configure/mode/throttleMs section — the plugin always streams
and exposes no knobs. Keep the endpoint contract summary.
The plugin passes no `security` option to createChatChannelPlugin, so
the SDK never invokes resolvePolicy/resolveAllowFrom against the resolved
account (openclaw/src/plugin-sdk/core.ts:538-539 only runs these when
ChatChannelSecurityOptions.dm is configured). And baseUrl is never read
by any plugin code — dispatch hits the controller via KILOCLAW_CONTROLLER_URL
from env, not via the resolved account.

Collapse ResolvedKiloChatAccount to just { accountId } (the SDK requires
that one). Drop readChannelSection, DEFAULT_BASE_URL, the baseUrl/dmPolicy/
allowFrom parsing in resolveAccount, and the matching configSchema entries
in openclaw.plugin.json. Stop writing those fields from config-writer since
nothing consumes them — only `enabled` is meaningful.

Also fix a stale buildConfiguredSecrets test (kiloclaw.test.ts) left over
from the earlier secret-catalog rollback: it still asserted a 'kilo-chat'
key on the configured-secrets map.
The CreatePreviewStreamOptions type exposed setTimer/clearTimer injection
hooks for tests, but every test uses vi.useFakeTimers() instead — the
seams were never overridden. Inline setTimeout/clearTimeout directly.

Also fix a stale channel.test.ts assertion that referenced
ResolvedKiloChatAccount.baseUrl, which was removed in the previous
account-resolution trim.
…gistration gate

Fix A stripped baseUrl from channels['kilo-chat'] because the plugin
never reads it. Turns out OpenClaw's `hasMeaningfulChannelConfig`
(config-presence.ts:153) requires at least one key other than `enabled`
for a channel to count as configured; without it the plugin loads in
`setup-runtime` mode instead of `full`, which skips `registerFull(api)`
and the /plugins/kilo-chat/webhook HTTP route never registers.

Put baseUrl back in the configSchema and config-writer (dmPolicy/
allowFrom stay gone — those are genuinely unread). Caught by local
e2e: the webhook curl was returning 404 despite the plugin being
loaded.
Steps to exercise the full pipeline on a laptop: build the image, run
a fake upstream, start the container with KILOCODE_API_KEY, trigger
outbound and inbound flows, observe POST + PATCH for streaming. Covers
the gotchas found while bringing this up (registration gate, onboard
skip, default-model rejection, proxy-token flag).
@kilo-code-bot
Copy link
Copy Markdown
Contributor

kilo-code-bot bot commented Apr 13, 2026

Code Review Summary

Status: 6 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 6
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 42 sentAt is only checked for non-empty string content, so malformed timestamps flow into Date.parse() as NaN.
services/kiloclaw/plugins/kilo-chat/LOCAL_E2E.md 74 The fake upstream example logs the full authorization header and raw request body, which demonstrates logging sensitive data.
services/kilo-chat/src/routes/messages.ts 12 conversationId in create/edit/delete is only validated as a non-empty string before calling CONVERSATION_DO.idFromName(...), unlike the other conversation routes that require a ULID.
services/kilo-chat/src/index.ts 40 Missing webhook config currently ack()s every queued message, permanently dropping bot-trigger webhooks instead of retrying or dead-lettering them.
services/kilo-chat/src/do/conversation-do.ts 304 SSE replay keys off message IDs only, so reconnects cannot replay updates or deletes for messages whose create event was already seen before disconnect.
services/kiloclaw/src/index.ts 1136 deliverChatWebhook() still decodes sandbox IDs with userIdFromSandboxId(), which breaks for instance-keyed ki_... sandboxes and routes those webhooks to the wrong DO.
Other Observations (not in diff)

None.

Files Reviewed (1 files)
  • services/kilo-chat/wrangler.jsonc - 0 issues

Reviewed by gpt-5.4-20260305 · 1,498,742 tokens

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 2 commits April 14, 2026 20:24
getBotMembersExcluding returns { id, kind } objects, not strings.
The queue payload needs the string id, not the full object.
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