28 KiB
SimpleX Support Bot — Implementation Plan
1. Executive Summary
SimpleX Chat support bot — standalone Node.js app using simplex-chat-nodejs native NAPI binding. Two ChatApi instances (main bot + Grok agent identity) in one process, each with own SQLite database. No external CLI processes. Implements 4-step flow: Welcome → TeamQueue → GrokMode/TeamPending → TeamLocked.
2. Architecture
┌─────────────────────────────────────────────────┐
│ Support Bot Process (Node.js) │
│ │
│ mainChat: ChatApi ← ChatApi.init("./data/bot") │
│ • Business address, event routing, state mgmt │
│ • DB: data/bot_chat.db + data/bot_agent.db │
│ │
│ grokChat: ChatApi ← ChatApi.init("./data/grok") │
│ • Grok identity, auto-joins groups │
│ • DB: data/grok_chat.db + data/grok_agent.db │
│ │
│ conversations: Map<groupId, ConversationState> │
│ grokGroupMap: Map<mainGroupId, grokGroupId> │
│ GrokApiClient → api.x.ai/v1/chat/completions │
└─────────────────────────────────────────────────┘
- Single Node.js process, no external dependencies except Grok API
- Two
ChatApiinstances via native NAPI — each embeds simplex-chat core - Business address auto-accept creates a group per customer (business chat = special group)
- Grok agent is a separate identity that gets invited as group member, making Grok appear as a separate participant per spec
- Cross-instance group ID correlation via protocol-level
memberId(string, same across both databases)
3. Project Structure
apps/simplex-chat-support-bot/
├── package.json # deps: simplex-chat, @simplex-chat/types
├── tsconfig.json # ES2022, strict, Node16 module resolution
├── src/
│ ├── index.ts # Entry: parse config, init instances, run
│ ├── config.ts # CLI arg parsing, ID:name validation, Config type
│ ├── bot.ts # SupportBot class: state mgmt, event dispatch, routing
│ ├── state.ts # ConversationState union type
│ ├── grok.ts # GrokApiClient: xAI API wrapper, system prompt, history
│ ├── messages.ts # All user-facing message templates (verbatim from spec)
│ └── util.ts # isWeekend, logging helpers
├── data/ # SQLite databases (created at runtime)
└── docs/
└── simplex-context.md # Curated SimpleX docs injected into Grok system prompt
4. Configuration — ID:name Format
All entity references use ID:name format. The bot validates each pair at startup against live data from apiListContacts() / apiListGroups().
CLI args:
| Arg | Required | Default | Format | Purpose |
|---|---|---|---|---|
--db-prefix |
No | ./data/bot |
path | Main bot database file prefix |
--grok-db-prefix |
No | ./data/grok |
path | Grok agent database file prefix |
--team-group |
Yes | — | ID:name |
Group for forwarding customer messages to team |
--team-members |
Yes | — | ID:name,... |
Comma-separated team member contacts |
--grok-contact |
Yes* | — | ID:name |
Grok agent's contactId in main bot's database |
--group-links |
No | "" |
string | Public group link(s) for welcome message |
--timezone |
No | "UTC" |
IANA tz | For weekend detection (24h vs 48h) |
--first-run |
No | false | flag | Auto-establish contact between bot and Grok agent |
*--grok-contact required unless --first-run is used.
Env vars: GROK_API_KEY (required) — xAI API key.
interface Config {
dbPrefix: string
grokDbPrefix: string
teamGroup: {id: number; name: string}
teamMembers: {id: number; name: string}[]
grokContact: {id: number; name: string} | null // null during first-run
groupLinks: string
timezone: string
grokApiKey: string
firstRun: boolean
}
ID:name parsing:
function parseIdName(s: string): {id: number; name: string} {
const i = s.indexOf(":")
if (i < 1) throw new Error(`Invalid ID:name format: "${s}"`)
return {id: parseInt(s.slice(0, i), 10), name: s.slice(i + 1)}
}
Startup validation (exact API calls):
| What | API Call | Validation |
|---|---|---|
| Team group | mainChat.apiListGroups(userId) → find by groupId === config.teamGroup.id |
Assert groupProfile.displayName === config.teamGroup.name |
| Team members | mainChat.apiListContacts(userId) → find each by contactId |
Assert profile.displayName === member.name for each |
| Grok contact | mainChat.apiListContacts(userId) → find by contactId === config.grokContact.id |
Assert profile.displayName === config.grokContact.name |
Fail-fast with descriptive error on any mismatch.
5. State Machine
Keyed by groupId of business chat group. In-memory (restart resets; team group retains forwarded messages).
type ConversationState =
| {type: "welcome"}
| {type: "teamQueue"; userMessages: string[]}
| {type: "grokMode"; grokMemberGId: number; history: GrokMessage[]}
| {type: "teamPending"; teamMemberGId: number; grokMemberGId?: number; history?: GrokMessage[]}
| {type: "teamLocked"; teamMemberGId: number}
teamQueue.userMessages accumulates user messages for Grok initial context on /grok activation.
Transitions:
welcome ──(1st user msg)──> teamQueue
teamQueue ──(user msg)──> teamQueue (append to userMessages)
teamQueue ──(/grok)──> grokMode (userMessages → initial Grok history)
teamQueue ──(/team)──> teamPending
grokMode ──(user msg)──> grokMode (forward to Grok API, append to history)
grokMode ──(/team)──> teamPending (carry grokMemberGId + history)
teamPending ──(team member msg)──> teamLocked (remove Grok if present)
teamPending ──(/grok, grok present)──> teamPending (forward to Grok, still usable)
teamPending ──(/grok, no grok)──> reply "team mode"
teamLocked ──(/grok)──> reply "team mode", stay locked
teamLocked ──(any)──> no action (team sees directly)
6. Two-Instance Coordination
Problem: When main bot invites Grok agent to a business group, Grok agent's local groupId differs (different databases).
Solution: In-process maps correlated via protocol-level memberId (string, same across databases).
const pendingGrokJoins = new Map<string, number>() // memberId → mainGroupId
const grokGroupMap = new Map<number, number>() // mainGroupId → grokLocalGroupId
const reverseGrokMap = new Map<number, number>() // grokLocalGroupId → mainGroupId
Flow:
- Main bot:
mainChat.apiAddMember(mainGroupId, grokContactId, "member")→ responsemember.memberId - Store:
pendingGrokJoins.set(member.memberId, mainGroupId) - Grok agent receives
receivedGroupInvitationevent →evt.groupInfo.membership.memberIdmatches →grokChat.apiJoinGroup(evt.groupInfo.groupId)→ store bidirectional mapping - Send Grok response:
grokChat.apiSendTextMessage([T.ChatType.Group, grokGroupMap.get(mainGroupId)!], text)
Grok agent event subscriptions:
grokChat.on("receivedGroupInvitation", async ({groupInfo}) => {
const memberId = groupInfo.membership.memberId
const mainGroupId = pendingGrokJoins.get(memberId)
if (mainGroupId !== undefined) {
pendingGrokJoins.delete(memberId)
grokGroupMap.set(mainGroupId, groupInfo.groupId)
reverseGrokMap.set(groupInfo.groupId, mainGroupId)
await grokChat.apiJoinGroup(groupInfo.groupId)
}
})
7. Bot Initialization
Main bot uses bot.run() for setup automation (address, profile, commands), with only events parameter for full routing control:
let supportBot: SupportBot // set after bot.run returns
const [mainChat, mainUser, mainAddress] = await bot.run({
profile: {displayName: "SimpleX Support", fullName: ""},
dbOpts: {dbFilePrefix: config.dbPrefix},
options: {
addressSettings: {
businessAddress: true,
autoAccept: true,
welcomeMessage: welcomeMessage(config.groupLinks),
},
commands: [
{type: "command", keyword: "grok", label: "Ask Grok AI"},
{type: "command", keyword: "team", label: "Switch to team"},
],
useBotProfile: true,
},
events: {
acceptingBusinessRequest: (evt) => supportBot?.onBusinessRequest(evt),
newChatItems: (evt) => supportBot?.onNewChatItems(evt),
leftMember: (evt) => supportBot?.onLeftMember(evt),
deletedMemberUser: (evt) => supportBot?.onDeletedMemberUser(evt),
groupDeleted: (evt) => supportBot?.onGroupDeleted(evt),
connectedToGroupMember: (evt) => supportBot?.onMemberConnected(evt),
},
})
Grok agent uses direct ChatApi:
const grokChat = await ChatApi.init(config.grokDbPrefix)
let grokUser = await grokChat.apiGetActiveUser()
if (!grokUser) grokUser = await grokChat.apiCreateActiveUser({displayName: "Grok AI", fullName: ""})
await grokChat.startChat()
// Subscribe Grok event handlers (receivedGroupInvitation)
First-run mode (--first-run):
- Both instances init and create users
- Main bot:
mainChat.apiCreateLink(mainUser.userId)→ invitation link - Grok agent:
grokChat.apiConnectActiveUser(invLink) - Main bot:
mainChat.wait("contactConnected", 60000)— wait for connection - Print: "Grok contact established. ContactId=X. Use: --grok-contact X:GrokAI"
- Exit (user restarts without
--first-run)
Startup validation (after init, before event loop):
mainChat.apiListContacts(mainUser.userId)→ validate--team-membersand--grok-contactID:name pairsmainChat.apiListGroups(mainUser.userId)→ validate--team-groupID:name pair
8. Event Processing
Main bot event handlers:
| Event | Handler | Action |
|---|---|---|
acceptingBusinessRequest |
onBusinessRequest |
conversations.set(groupInfo.groupId, {type: "welcome"}) |
newChatItems |
onNewChatItems |
For each chatItem: identify sender, extract text, dispatch to routing |
leftMember |
onLeftMember |
If customer left → delete state. If team member left → revert to teamQueue. If Grok left → clear grokMemberGId. |
deletedMemberUser |
onDeletedMemberUser |
Bot removed from group → delete state |
groupDeleted |
onGroupDeleted |
Delete state, delete grokGroupMap entry |
connectedToGroupMember |
onMemberConnected |
Log for debugging |
Sender identification in newChatItems:
for (const ci of evt.chatItems) {
const {chatInfo, chatItem} = ci
if (chatInfo.type !== "group") continue
const groupInfo = chatInfo.groupInfo
if (!groupInfo.businessChat) continue // only process business chats
const groupId = groupInfo.groupId
const state = conversations.get(groupId)
if (!state) continue
if (chatItem.chatDir.type === "groupSnd") continue // our own message
if (chatItem.chatDir.type !== "groupRcv") continue
const sender = chatItem.chatDir.groupMember
const isCustomer = sender.memberId === groupInfo.businessChat.customerId
const isTeamMember = state.type === "teamPending" || state.type === "teamLocked"
? sender.groupMemberId === state.teamMemberGId
: false
const isGrok = (state.type === "grokMode" || state.type === "teamPending")
&& state.grokMemberGId === sender.groupMemberId
if (isGrok) continue // skip Grok messages (we sent them via grokChat)
if (isCustomer) onCustomerMessage(groupId, groupInfo, chatItem, state)
else if (isTeamMember) onTeamMemberMessage(groupId, state)
}
Text extraction:
function extractText(chatItem: T.ChatItem): string | null {
const text = util.ciContentText(chatItem)
return text?.trim() || null
}
9. Message Routing Table
onCustomerMessage(groupId, groupInfo, chatItem, state):
| State | Input | Actions | API Calls | Next State |
|---|---|---|---|---|
welcome |
any text | Forward to team, send queue reply | mainChat.apiSendTextMessage([Group, teamGroupId], fwd) + mainChat.apiSendTextMessage([Group, groupId], queueMsg) |
teamQueue (store msg) |
teamQueue |
/grok |
Activate Grok (invite, wait join, send accumulated msgs to Grok API, relay response) | mainChat.apiAddMember(groupId, grokContactId, "member") + mainChat.apiSendTextMessage([Group, groupId], grokActivatedMsg) + wait for join + grokChat.apiSendTextMessage([Group, grokLocalGId], grokResponse) |
grokMode |
teamQueue |
/team |
Add team member | mainChat.apiAddMember(groupId, teamContactId, "member") + mainChat.apiSendTextMessage([Group, groupId], teamAddedMsg) |
teamPending |
teamQueue |
other text | Forward to team, append to userMessages | mainChat.apiSendTextMessage([Group, teamGroupId], fwd) |
teamQueue |
grokMode |
/grok |
Ignore (already in grok mode) | — | grokMode |
grokMode |
/team |
Add team member (keep Grok for now) | mainChat.apiAddMember(groupId, teamContactId, "member") + mainChat.apiSendTextMessage([Group, groupId], teamAddedMsg) |
teamPending (carry grokMemberGId + history) |
grokMode |
other text | Forward to Grok API + team | Grok API call + grokChat.apiSendTextMessage([Group, grokLocalGId], response) + mainChat.apiSendTextMessage([Group, teamGroupId], fwd) |
grokMode (append history) |
teamPending (grok present) |
/grok |
Forward to Grok (still usable) | Grok API call + grokChat.apiSendTextMessage(...) |
teamPending |
teamPending (no grok) |
/grok |
Reply "team mode" | mainChat.apiSendTextMessage([Group, groupId], teamLockedMsg) |
teamPending |
teamPending |
/team |
Ignore (already team) | — | teamPending |
teamPending |
other text | No forwarding (team sees directly in group) | — | teamPending |
teamLocked |
/grok |
Reply "team mode" | mainChat.apiSendTextMessage([Group, groupId], teamLockedMsg) |
teamLocked |
teamLocked |
/team |
Ignore | — | teamLocked |
teamLocked |
other text | No action (team sees directly) | — | teamLocked |
10. Team Forwarding
async forwardToTeam(groupId: number, groupInfo: T.GroupInfo, text: string): Promise<void> {
const name = groupInfo.groupProfile.displayName || `group-${groupId}`
const fwd = `[${name} #${groupId}]\n${text}`
await this.mainChat.apiSendTextMessage(
[T.ChatType.Group, this.config.teamGroup.id],
fwd
)
}
async activateTeam(groupId: number, state: ConversationState): Promise<void> {
const teamContactId = this.config.teamMembers[0].id // round-robin or first available
const member = await this.mainChat.apiAddMember(groupId, teamContactId, "member")
this.conversations.set(groupId, {
type: "teamPending",
teamMemberGId: member.groupMemberId,
grokMemberGId: state.type === "grokMode" ? state.grokMemberGId : undefined,
history: state.type === "grokMode" ? state.history : undefined,
})
await this.mainChat.apiSendTextMessage(
[T.ChatType.Group, groupId],
teamAddedMessage(this.config.timezone)
)
}
11. Grok API Integration
class GrokApiClient {
constructor(private apiKey: string, private docsContext: string) {}
async chat(history: GrokMessage[], userMessage: string): Promise<string> {
const messages = [
{role: "system", content: this.systemPrompt()},
...history.slice(-20),
{role: "user", content: userMessage},
]
const resp = await fetch("https://api.x.ai/v1/chat/completions", {
method: "POST",
headers: {"Content-Type": "application/json", Authorization: `Bearer ${this.apiKey}`},
body: JSON.stringify({model: "grok-3", messages, max_tokens: 2048}),
})
if (!resp.ok) throw new Error(`Grok API ${resp.status}: ${await resp.text()}`)
const data = await resp.json()
return data.choices[0].message.content
}
private systemPrompt(): string {
return `You are a privacy expert and SimpleX Chat evangelist...\n\n${this.docsContext}`
}
}
Activating Grok (on /grok in teamQueue):
mainChat.apiAddMember(groupId, grokContactId, "member")→ storespendingGrokJoins.set(member.memberId, groupId)- Send bot activation message:
mainChat.apiSendTextMessage([Group, groupId], grokActivatedMsg) - Wait for Grok join: poll
grokGroupMap.has(groupId)with 30s timeout (or usemainChat.wait("connectedToGroupMember", pred, 30000)) - Build initial Grok history from
state.userMessages - Call Grok API with accumulated messages
- Send response via Grok identity:
grokChat.apiSendTextMessage([Group, grokGroupMap.get(groupId)!], response) - Transition to
grokModewith history
Fallback: If Grok API fails → send error message via mainChat.apiSendTextMessage, keep accumulated messages, stay in teamQueue.
12. One-Way Gate Logic
async onTeamMemberMessage(groupId: number, state: ConversationState): Promise<void> {
if (state.type !== "teamPending") return
// Remove Grok if present
if (state.grokMemberGId) {
try { await this.mainChat.apiRemoveMembers(groupId, [state.grokMemberGId]) } catch {}
grokGroupMap.delete(groupId)
reverseGrokMap.delete(/* grokLocalGroupId */)
}
this.conversations.set(groupId, {type: "teamLocked", teamMemberGId: state.teamMemberGId})
}
Timeline per spec:
- User sends
/team→apiAddMember→ state =teamPending(Grok still usable if present) - Team member sends message →
onTeamMemberMessage→ state =teamLocked, Grok removed viaapiRemoveMembers - Any
/grok→ reply "You are now in team mode. A team member will reply to your message."
13. Message Templates (verbatim from spec)
// Welcome (auto-reply via business address)
function welcomeMessage(groupLinks: string): string {
return `Hello! Feel free to ask any question about SimpleX Chat.\n*Only SimpleX Chat team has access to your messages.* This is a SimpleX Chat team bot — it is not any LLM or AI.${groupLinks ? `\n*Join public groups*: ${groupLinks}` : ""}\nPlease send questions in English, you can use translator.`
}
// After first message (teamQueue)
function teamQueueMessage(timezone: string): string {
const hours = isWeekend(timezone) ? "48" : "24"
return `Thank you for your message, it is forwarded to the team.\nIt may take a team member up to ${hours} hours to reply.\n\nClick /grok if your question is about SimpleX apps or network, is not sensitive, and you want Grok LLM to answer it right away. *Your previous message and all subsequent messages will be forwarded to Grok* until you click /team. You can ask Grok questions in any language and it will not see your profile name.\n\nWe appreciate if you try Grok: you can learn a lot about SimpleX Chat from it. It is objective, answers the way our team would, and it saves our team time.`
}
// Grok activated
const grokActivatedMessage = `*You are now chatting with Grok. You can send questions in any language.* Your message(s) have been forwarded.\nSend /team at any time to switch to a human team member.`
// Team added
function teamAddedMessage(timezone: string): string {
const hours = isWeekend(timezone) ? "48" : "24"
return `A team member has been added and will reply within ${hours} hours. You can keep describing your issue — they will see the full conversation.`
}
// Team mode locked
const teamLockedMessage = "You are now in team mode. A team member will reply to your message."
Weekend detection:
function isWeekend(timezone: string): boolean {
const day = new Intl.DateTimeFormat("en-US", {timeZone: timezone, weekday: "short"}).format(new Date())
return day === "Sat" || day === "Sun"
}
14. Complete API Call Map (100% Coverage)
| # | Operation | When | ChatApi Instance | Method | Parameters | Response Type | Error Handling |
|---|---|---|---|---|---|---|---|
| 1 | Init main bot | Startup | mainChat | bot.run() (wraps ChatApi.init) |
dbFilePrefix, profile, addressSettings | [ChatApi, User, UserContactLink] |
Exit on failure |
| 2 | Init Grok agent | Startup | grokChat | ChatApi.init(grokDbPrefix) |
dbFilePrefix | ChatApi |
Exit on failure |
| 3 | Get/create Grok user | Startup | grokChat | apiGetActiveUser() / apiCreateActiveUser(profile) |
profile: {displayName: "Grok AI"} | User |
Exit on failure |
| 4 | Start Grok chat | Startup | grokChat | startChat() |
— | void | Exit on failure |
| 5 | Validate team group | Startup | mainChat | apiListGroups(userId) |
userId | GroupInfo[] |
Exit if ID:name mismatch |
| 6 | Validate contacts | Startup | mainChat | apiListContacts(userId) |
userId | Contact[] |
Exit if ID:name mismatch |
| 7 | First-run: create link | First-run | mainChat | apiCreateLink(userId) |
userId | string (invitation link) |
Exit on failure |
| 8 | First-run: connect | First-run | grokChat | apiConnectActiveUser(invLink) |
connLink | ConnReqType |
Exit on failure |
| 9 | First-run: wait | First-run | mainChat | wait("contactConnected", 60000) |
event, timeout | ChatEvent | undefined |
Exit on timeout |
| 10 | Send msg to customer | Various | mainChat | apiSendTextMessage([Group, groupId], text) |
chat, text | AChatItem[] |
Log error |
| 11 | Forward to team | welcome→teamQueue, teamQueue msg | mainChat | apiSendTextMessage([Group, teamGroupId], fwd) |
chat, formatted text | AChatItem[] |
Log error |
| 12 | Invite Grok to group | /grok in teamQueue | mainChat | apiAddMember(groupId, grokContactId, "member") |
groupId, contactId, role | GroupMember |
Send error msg, stay in teamQueue |
| 13 | Grok joins group | receivedGroupInvitation | grokChat | apiJoinGroup(groupId) |
groupId | GroupInfo |
Log error |
| 14 | Grok sends response | After Grok API reply | grokChat | apiSendTextMessage([Group, grokLocalGId], text) |
chat, text | AChatItem[] |
Send error msg via mainChat |
| 15 | Invite team member | /team | mainChat | apiAddMember(groupId, teamContactId, "member") |
groupId, contactId, role | GroupMember |
Send error msg to customer |
| 16 | Remove Grok | Team member msg in teamPending | mainChat | apiRemoveMembers(groupId, [grokMemberGId]) |
groupId, memberIds | GroupMember[] |
Ignore (may have left) |
| 17 | Update bot profile | Startup (via bot.run) | mainChat | apiUpdateProfile(userId, profile) |
userId, profile with peerType+commands | UserProfileUpdateSummary |
Log warning |
| 18 | Set address settings | Startup (via bot.run) | mainChat | apiSetAddressSettings(userId, settings) |
userId, {businessAddress, autoAccept, welcomeMessage} | void | Exit on failure |
15. Error Handling
| Scenario | Handling |
|---|---|
| ChatApi init fails | Log error, exit (let process manager restart) |
| Grok API error (HTTP/timeout) | mainChat.apiSendTextMessage "Grok temporarily unavailable", revert to teamQueue |
apiAddMember fails (Grok) |
mainChat.apiSendTextMessage error msg, stay in teamQueue |
apiAddMember fails (team) |
mainChat.apiSendTextMessage error msg, stay in current state |
apiRemoveMembers fails |
Catch and ignore (member may have left) |
| Grok join timeout (30s) | mainChat.apiSendTextMessage "Grok unavailable", stay in teamQueue |
Customer leaves (leftMember where member is customer) |
Delete conversation state, delete grokGroupMap entry |
| Group deleted | Delete conversation state, delete grokGroupMap entry |
Grok leaves during teamPending |
Clear grokMemberGId from state, keep teamPending |
| Team member leaves | Revert to teamQueue (accumulate messages again) |
Bot removed from group (deletedMemberUser) |
Delete conversation state |
| Grok agent connection lost | Log error; Grok features unavailable until restart |
apiSendTextMessage fails |
Log error, continue (message lost but bot stays alive) |
| Config validation fails | Print descriptive error with actual vs expected name, exit |
16. Implementation Sequence
Phase 1: Scaffold
- Create project:
package.json,tsconfig.json - Implement
config.ts: CLI arg parsing, ID:name format,Configtype - Implement
index.ts: init both ChatApi instances, verify profiles - Implement
util.ts:isWeekend, logging - Verify: Both instances init, print user profiles, validate config
Phase 2: State machine + event loop
- Implement
state.ts:ConversationStateunion type - Implement
bot.ts:SupportBotclass withconversationsmap - Handle
acceptingBusinessRequest→ init state aswelcome - Handle
newChatItems→ sender identification → customer message dispatch - Implement welcome → teamQueue transition + team forwarding
- Implement
messages.ts: all templates - Verify: Customer connects → welcome auto-reply → sends msg → forwarded to team group → queue reply received
Phase 3: Grok integration
- Implement
grok.ts:GrokApiClientwith system prompt + docs injection - Implement Grok agent event handler (
receivedGroupInvitation→ auto-join) - Implement
activateGrok: add member, ID mapping, wait for join, Grok API call, send response via grokChat - Implement
forwardToGrok: ongoing message routing in grokMode - Verify:
/grok→ Grok joins as separate participant → Grok responses appear from Grok profile
Phase 4: Team mode + one-way gate
- Implement
activateTeam: add team member - Implement
onTeamMemberMessage: detect team msg → lock → remove Grok - Implement
/grokrejection inteamLocked - Verify: Full flow including: teamQueue → /grok → grokMode → /team → teamPending → team msg → teamLocked → /grok rejected
Phase 5: Polish + first-run
- Implement
--first-runauto-contact establishment - Handle edge cases: customer leave, group delete, Grok timeout, member leave
- Write
docs/simplex-context.mdfor Grok prompt injection - End-to-end test all flows
17. Self-Review Requirement
Mandatory for all implementation subagents:
Each code artifact must undergo adversarial self-review/fix loop:
- Write/edit code
- Self-review against this plan: check correctness, completeness, consistency, all state transitions covered, all API calls match the plan, all error cases handled
- Fix any issues found
- Repeat review until 2 consecutive zero-issue passes
- Only then report completion
- User reviews and provides feedback
- If changes needed → return to step 1 (review cycle restarts)
- Done when: 2 clean LLM passes AND user finds no issues
Any edit restarts the review cycle. Batch changes within a round.
18. Verification
First-run setup:
cd apps/simplex-chat-support-bot
npm install
npx ts-node src/index.ts --first-run --db-prefix ./data/bot --grok-db-prefix ./data/grok
# → Prints: "Grok contact established. ContactId=X. Use: --grok-contact X:GrokAI"
Normal run:
npx ts-node src/index.ts \
--team-group 1:SupportTeam \
--team-members 2:Alice,3:Bob \
--grok-contact 4:GrokAI \
--timezone America/New_York \
--group-links "https://simplex.chat/contact#..."
Test scenarios:
- Connect from SimpleX client to bot's business address → verify welcome message
- Send question → verify forwarded to team group with
[CustomerName #groupId]prefix, queue reply received - Send
/grok→ verify Grok joins as separate participant, responses appear from "Grok AI" profile - Send text in grokMode → verify Grok response + forwarded to team
- Send
/team→ verify team member added, team added message - Send team member message → verify Grok removed, state locked
- Send
/grokafter lock → verify "team mode" reply - Test weekend: set timezone to weekend timezone → verify "48 hours" in messages
- Customer disconnects → verify state cleanup
- Grok API failure → verify error message, graceful fallback to teamQueue
Critical Reference Files
- Native library API:
packages/simplex-chat-nodejs/src/api.ts(ChatApi class — all methods) - Bot automation:
packages/simplex-chat-nodejs/src/bot.ts(bot.run — setup helper) - Utilities:
packages/simplex-chat-nodejs/src/util.ts(ciContentText, ciBotCommand, chatInfoRef) - Types:
packages/simplex-chat-client/types/typescript/src/types.ts(BusinessChatInfo, GroupMember, CIDirection, etc.) - Events:
packages/simplex-chat-client/types/typescript/src/events.ts(CEvt — all event types) - Product spec:
apps/multiplatform/plans/20260207-support-bot.md