Channels — Implementation Plan
Status: Plan only — no implementation started Last updated: 7.apr.2026 Strategy: see channels-roadmap.md
Current state
Section titled “Current state”v0.9.0 is in development. Telegram is the only channel and is working. Nothing in this plan should be implemented until v0.9.0 is validated in production.
Conclusion
Section titled “Conclusion”Two channels for v0.9.0: Telegram (done) + Web UI (new). Signal and WhatsApp deferred until real user demand exists. This covers phone dependency, privacy, and global reach without Java or Meta.
Exact steps to v0.9.0
Section titled “Exact steps to v0.9.0”Pre-condition: production validate v0.9.0
Section titled “Pre-condition: production validate v0.9.0”Before writing a single line of channels code, confirm these work on a real install:
- Telegram bot receives and responds to messages
-
npm run backupproduces a valid tarball -
npm run doctorreports healthy - Metrics endpoint responds on port 9100
- Grafana dashboard shows real data from VictoriaMetrics
- ZFS snapshots are being taken by Sanoid
- Management jail starts cleanly on boot
Fix anything broken before proceeding.
v0.9.0 implementation sequence
Section titled “v0.9.0 implementation sequence”Step 1 — Onboarding refactor
Make setup channel-agnostic. Currently assumes Telegram unconditionally.
setup/onboarding.ts— no change needed (doesn’t touch Telegram)setup/telegram-auth.ts— wrap in a channel selection step so Telegram is one option, not the only optionsrc/index.ts— guardTELEGRAM_BOT_TOKENcheck behindTELEGRAM_ENABLED=truerather than failing hard if token missing.env— addTELEGRAM_ENABLED=true(default true, no breaking change)
Step 2 — Invite code system
New users register by sending a code. Operator generates codes via CLI. Full implementation was prototyped on 16.mar.2026 — see git history or re-derive from channels-plan.md decisions section above.
Files to create:
src/invite.ts—createInviteCode,tryConsumeInviteCode,jidToFolderscripts/invite.ts—npm run invite [--ttl Nd]→ prints new codescripts/users.ts—npm run users→ lists registered usersscripts/codes.ts—npm run codes→ lists all active/used invite codesscripts/revoke.ts—npm run revoke <jid>→ removes a registered userscripts/revoke-code.ts—npm run revoke-code <code>→ deletes unused codescripts/suspend.ts—npm run suspend <jid>/npm run unsuspend <jid>
Files to modify:
src/db.ts—invite_codestable (withexpires_at,revoked_at),invited_bycolumn onregistered_groups,suspended_atonregistered_groupssrc/types.ts—invitedBy?: string,suspendedAt?: DateonRegisteredGroupsrc/channels/telegram.ts—onInviteCode?callback in opts, check suspensionsrc/index.ts— wire callback, auto-register on valid code, reject suspended JIDspackage.json— addinvite,users,codes,revoke,revoke-code,suspend,unsuspendscripts
Key decisions:
- Single-use codes, optional TTL (default: no expiry)
- Server stores invite_code → JID mapping permanently (enables re-link on browser clear)
- Store
invited_byfrom day one for future delegated invites - Operator-only invite generation to start (Model A)
- Audit log:
logs/invite-audit.log
Step 3 — Web UI channel
Files to create:
src/channels/web.ts— implementsChannel, WebSocket server, JID prefixweb:- Frontend: single HTML/CSS/JS chat page
setup/web-ui.ts— deploy frontend to CMS jail nginx
Files to modify:
src/index.ts— start web channel onWEB_UI_PORTifWEB_UI_ENABLED=true.env—WEB_UI_ENABLED=false,WEB_UI_PORT=3001setup/index.ts— addweb-uistepsetup/pf.ts— allow/restrictWEB_UI_PORTas appropriate
User identity on Web UI: invite code entered on first visit, stored in localStorage. Same invite system as Step 2 — no new concepts for the user.
Step 4 — Version bump and docs
- Bump to v0.9.0
- Update
docs/internal/sessions/with a session log for this work - Update install docs to mention Web UI and invite codes
Part A — Decisions
Section titled “Part A — Decisions”Decision 1 — End user registration model ✓ DECIDED: Invite code
Section titled “Decision 1 — End user registration model ✓ DECIDED: Invite code”Operator runs npm run invite → gets a short code (e.g. CLAWDIE-A3X7),
shares it out of band. User sends it as their first message. Bot registers them automatically.
Decision 2 — Invite code behaviour ✓ DECIDED: Single-use, optional TTL
Section titled “Decision 2 — Invite code behaviour ✓ DECIDED: Single-use, optional TTL”One code per person, works until used.
Store invited_by: jid | 'operator' on every registered user from day one —
enables upgrade to delegated invites later without a migration.
Security requirements:
- Optional TTL:
npm run invite --ttl 7d(default: no expiry). TTL stored ininvite_codes.expires_at(nullable). Expired codes rejected silently. - Audit log: every code creation and consumption appended to
logs/invite-audit.logwith timestamp, code, JID (on consume), and source. - Revoke unused codes:
npm run revoke-code <code>hard-deletes an unused code. Used codes cannot be revoked (JID is already registered — usenpm run revoke <jid>instead). - List codes:
npm run codesshows all active codes with status:
CODE CREATED EXPIRES STATUS JIDCLAWDIE-A3X7 2026-03-15 — used web:a3x7CLAWDIE-B9K2 2026-03-17 2026-03-24 active —Schema change: add expires_at TIMESTAMP NULL and revoked_at TIMESTAMP NULL
to invite_codes table. Rollback migration: DROP COLUMN expires_at; DROP COLUMN revoked_at;
Decision 3 — Web UI user identity ✓ DECIDED: Invite code login
Section titled “Decision 3 — Web UI user identity ✓ DECIDED: Invite code login”User enters their invite code on first Web UI visit. Code becomes their persistent identity — survives browser clears, works on any device. Reuses the same invite system as Decision 1. No OAuth, no Google dependency.
Identity recovery: localStorage is used for convenience (avoids re-entry on
repeat visits) but is NOT the authoritative identity store. The server maintains
a permanent invite_code → jid mapping in the invite_codes table. If a user
clears browser data, entering the same invite code re-links them to their
existing JID — no lockout. tryConsumeInviteCode must check for an existing
mapping before creating a new JID:
async function tryConsumeInviteCode(code: string): Promise<{ jid: string; isNew: boolean }> { const existing = await db.getJidByInviteCode(code); if (existing) return { jid: existing, isNew: false }; // re-link, not new // ... create new JID, store mapping}Decision 4 — Signal ✓ DEFERRED
Section titled “Decision 4 — Signal ✓ DEFERRED”signal-cli requires Java. Java in a jail adds attack surface. No mature non-Java Signal client exists. Revisit only when real users ask for it.
Decision 5 — WhatsApp ✓ DEFERRED
Section titled “Decision 5 — WhatsApp ✓ DEFERRED”Meta-owned, metadata collected, unofficial library only. Revisit only when real users ask for it. If added: use Baileys (pure Node.js, no browser/Puppeteer dependency).
Part B — Implementation steps
Section titled “Part B — Implementation steps”Step 1 — Onboarding refactor (prerequisite for everything)
Section titled “Step 1 — Onboarding refactor (prerequisite for everything)”What changes:
setup/onboarding.ts— replace hardcoded Telegram token prompt with channel selectionsetup/telegram.ts— Telegram becomes one option, not the default assumptionsrc/db.ts— addinvite_codestable, addinvited_byfield to registered userssrc/index.ts— handle first-message invite code registration logicnpm run invite— new CLI command to generate invite codesnpm run users— list registered users with channel and invited_bynpm run revoke <jid>— remove a registered user
What does not change:
Channelinterface — already correct- Message pipeline — already channel-agnostic
- JID storage — already channel-prefixed
Step 2 — Web UI (Tier 1)
Section titled “Step 2 — Web UI (Tier 1)”What changes:
src/channels/web.ts— implementsChannel, WebSocket server, JID prefixweb:src/index.ts— start web channel onWEB_UI_PORT- Frontend: single HTML/CSS/JS chat page served from CMS jail via nginx
setup/web-ui.ts— deploy frontend, configure nginx route.env—WEB_UI_ENABLED,WEB_UI_PORT- Invite code entered on first visit → stored in localStorage for convenience
- localStorage is not authoritative — server re-links on re-entry (see Decision 3)
Rate limiting (WebSocket):
- Per-IP: max 10 messages/minute; excess connections dropped with
429 - Per-JID: suspended users rejected at WebSocket handshake (no session created)
- Implementation: in-memory
Map<ip, { count, resetAt }>insrc/channels/web.ts
Operator user management:
npm run suspend <jid>— setssuspended_at, blocks all channels for that JIDnpm run unsuspend <jid>— clearssuspended_at, restores access- Suspension does not delete data — full history preserved
Access options:
- Private: Tailscale only (no public exposure)
- Public:
chat.yourdomain.comvia nginx, invite code is the only gate
Step 3 — Signal (deferred)
Section titled “Step 3 — Signal (deferred)”Add only when real users request it. Java/JRE in a jail is the accepted tradeoff at that point.
Step 4 — WhatsApp (deferred)
Section titled “Step 4 — WhatsApp (deferred)”Add only when real users request it. Tier 2, opt-in, informed-consent warning in setup.
Step 5 — WeChat (future / community)
Section titled “Step 5 — WeChat (future / community)”Requires Chinese business registration. Not part of main Clawdie install.
JID prefix reserved: wc:
Summary
Section titled “Summary”| Step | Target version | Priority |
|---|---|---|
| 1. Onboarding refactor | v0.9.0 | Now — blocks everything else |
| 2. Web UI | v0.9.0 | Now — solves phone dependency |
| 3. Signal | Future | Only on user demand |
| 4. WhatsApp | Future | Only on user demand |
| 5. WeChat | Community | Not in scope |