npm package: @hzttt/lucychat
Description: OpenClaw LucyChat channel plugin
Latest version: 2026.2.26

# LucyChat Channel Plugin

`lucychat` is an OpenClaw channel plugin + Go relay for custom clients.

V1 scope:

- Direct message text inbound
- AI reply text + media (image/audio) outbound
- At-least-once delivery with client `ack`
- In-memory relay queue (no persistence on restart)
- Media is sent as binary upload (`plugin -> relay -> client`), never as local file path

## Structure

- `index.ts`, `src/*`: OpenClaw channel plugin
- `relay-go/`: standalone Go relay (`ws` + enqueue bridge)
- `web/`: minimal browser client

## Plugin Install (local)

```bash
openclaw plugins install -l ./extensions/lucychat
```

## OpenClaw Config

Add to `~/.openclaw/openclaw.json`:

```json5
{
  channels: {
    lucychat: {
      enabled: true,
      inboundPath: "/api/channels/lucychat/inbound",
      pluginToken: "replace-with-shared-plugin-token",
      relayBaseUrl: "http://127.0.0.1:31890",
      relayEnqueuePath: "/api/v1/plugin/enqueue",
      relayUploadPath: "/api/v1/plugin/media/upload",
      mediaMaxMb: 32,
      compressImageOnOverflow: true,
      requestTimeoutMs: 5000,
      retry: {
        maxAttempts: 3,
        baseDelayMs: 200,
        maxDelayMs: 1200,
      },
    },
  },
}
```

## Relay Config (env)

```bash
export LUCYCHAT_CLIENT_TOKEN="replace-client-token"
export LUCYCHAT_PLUGIN_TOKEN="replace-with-shared-plugin-token"
export LUCYCHAT_GATEWAY_TOKEN="replace-with-openclaw-gateway-auth-token" # optional; defaults to LUCYCHAT_PLUGIN_TOKEN
export LUCYCHAT_PLUGIN_INBOUND_URL="http://127.0.0.1:18789/api/channels/lucychat/inbound"
export LUCYCHAT_RELAY_BIND=":31890"
export LUCYCHAT_MEDIA_MAX_BYTES="33554432" # 32MB hard cap
export LUCYCHAT_MEDIA_URL_TTL_MS="1800000" # 30m signed URL TTL
export LUCYCHAT_MEDIA_SIGNING_KEY="replace-with-signing-key" # optional, defaults to LUCYCHAT_PLUGIN_TOKEN
# export LUCYCHAT_PUBLIC_BASE_URL="https://relay.example.com" # optional, default inferred from request host
```

Note: OpenClaw protects `/api/channels/*` with gateway auth. If your gateway auth token differs
from `LUCYCHAT_PLUGIN_TOKEN`, set `LUCYCHAT_GATEWAY_TOKEN` so relay-go can pass gateway auth and
still present the plugin token via `x-lucychat-plugin-token`.

## Run Relay

```bash
cd extensions/lucychat/relay-go
go run ./cmd/lucychat-relay
```

## Web Client

Serve static files:

```bash
cd extensions/lucychat/web
python3 -m http.server 5174
```

Open `http://127.0.0.1:5174`, fill:

- `Relay WS URL`: `ws://127.0.0.1:31890/ws`
- `appUserId`: your custom user id
- `clientToken`: `LUCYCHAT_CLIENT_TOKEN`

The default web UI is user-facing: it only renders visible chat replies. Protocol-level frames like
`accepted`/`deliver` metadata are not shown in the message timeline.

### iOS Local HTTP Readonly Sync Demo

When the iOS app is in foreground, it also starts a local HTTP server on port `31901`.

You can open `http://127.0.0.1:5174/http-sync.html` and set:

- `iOS HTTP Base URL`: `http://<iphone-lan-ip>:31901`
- `Cursor`: `0` for initial snapshot

Notes:

- This is a demo-only, readonly sync endpoint.
- Foreground-only behavior: when app enters background, local HTTP sync stops.
- The iOS app also serves the same readonly page directly at `http://<iphone-lan-ip>:31901/http-sync.html`.

## Protocol

### WS (client <-> relay)

- `auth`: `{type:"auth", appUserId, clientToken}`
- `send`: `{type:"send", messageId, text, senderName?}`
- `accepted`: `{type:"accepted", messageId, requestId, sessionKey, duplicate?}`
- `deliver`: `{type:"deliver", item:{id,requestId,text,media?,createdAt}}`
- `ack`: `{type:"ack", ids:["..."]}`
- `error`: `{type:"error", code, message}`

`deliver.item.media[]` shape:

- `id: string`
- `kind: "image" | "audio" | "video" | "document" | "unknown"`
- `url: string` (signed relay download URL)
- `mimeType?: string`
- `fileName?: string`
- `sizeBytes?: number`
- `audioAsVoice?: boolean`

### Relay HTTP

- `GET /healthz`
- `POST /api/v1/plugin/enqueue` (`Authorization: Bearer <plugin-token>`)
- `POST /api/v1/plugin/media/upload` (`Authorization: Bearer <plugin-token>`, multipart)
- `GET /api/v1/media/:mediaId?uid=<appUserId>&exp=<unix>&sig=<hmac>`

### Plugin HTTP

- `POST /api/channels/lucychat/inbound` (`Authorization: Bearer <plugin-token>`)

### iOS Local HTTP (demo, no auth)

- `GET /healthz`
- `GET /v1/messages?cursor=<number>&limit=<number>`
- `GET /v1/stream?cursor=<number>` (SSE, event type: `upsert`)

## Smoke Script

```bash
bash extensions/lucychat/scripts/smoke.sh
```

## Security Note

This v1 accepts HTTP-only deployment if you choose to expose it publicly.

That is risky. For non-local deployments, add TLS termination (reverse proxy) and strong token hygiene.

## Media Behavior

- Local path + HTTP(S) media sources are both supported by the plugin.
- Plugin uploads media bytes to relay first, then enqueues message metadata.
- Single file hard cap is 32MB by default (`mediaMaxMb` / `LUCYCHAT_MEDIA_MAX_BYTES`).
- If media exceeds cap:
  - images: plugin tries compression and retries upload
  - non-images: request fails with a clear error
- Signed media URLs expire after 30 minutes by default.