From zaileys-official
Scaffolds a complete zaileys WhatsApp bot project from a spec, supporting both WhatsApp Web (QR/pairing) and Meta Cloud API providers. Generates a layered src/ structure, package.json, tsconfig, and env config.
How this skill is triggered — by the user, by Claude, or both
Slash command
/zaileys-official:zaileys-scaffoldThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Generate a complete, runnable zaileys WhatsApp bot from a short spec. zaileys is a TypeScript
Generate a complete, runnable zaileys WhatsApp bot from a short spec. zaileys is a TypeScript
WhatsApp library over baileys; import is always import { Client } from 'zaileys'. Target: Node 20+,
ESM ("type": "module"). Output a layered src/ project (never a single flat bot.ts),
package.json, tsconfig.json, .env.example, .gitignore, and run steps.
Existing project conventions always win over this skill's canonical layout. Before generating anything, inspect the target directory:
src/ / package.json): apply the canonical PROJECT STRUCTURE
CONTRACT below verbatim.package.json, src/, established folders, or even
empty-but-intentional dirs the developer pre-created like src/prompts/, src/tools/): conform
to what is there. Map each role onto the existing folders and match their conventions — folder
names, file naming (kebab vs camel), barrel (index.ts) vs flat, import style, default vs named
exports. Do NOT rename, move, or re-layer their files; do NOT impose this skill's folder names.How to detect intended structure: an empty folder a developer created (src/prompts/, src/tools/,
src/pkg/) is a deliberate signal — put that role's files there, do not invent a parallel location.
When the existing convention for a given role is genuinely ambiguous (e.g. an empty folder whose purpose
you cannot infer from its name), ASK rather than guess.
Within each regime above, output is reproducible — a pure function of (spec + the project's existing conventions). Greenfield: same (use case + features + auth + storage) → byte-for-byte same tree, every session, every developer. Brownfield: same spec + same existing layout → same placement, every session. Never improvise structure per session; decide from the existing project first, then the tables below.
Fixed conventions (never vary):
config/ (env), client.ts (Client factory), handlers/ (event wiring),
commands/ (slash commands), services/ (external integrations), features/ (domain logic),
lib/ (pure helpers).src/index.ts is the only file with top-level execution — it builds the client and wires
everything. Every other module exports functions only; no side effects at import time.message.ts, connection.ts, openai.ts, ai-agent.ts).registerXxx(client); services export factory
functions; features export plain logic functions. No default exports.client.ts, adds a webhook route, and swaps auth for a token — see CLOUD PROVIDER VARIANT below.echo,
ai-agent, slash-commands, interactive-buttons, broadcast. Use cases vary widely and are
not limited to AI; map any new one onto the placement table (services = external I/O, features =
domain logic, handlers = WhatsApp events).qr (default) or pairing (needs phoneNumber, E.164 digits, no +).file (default) / memory / sqlite / postgres / redis / convex.ai-agent: provider (OpenAI / Anthropic / Gemini / OpenAI-compatible) and whether to
include conversation memory + tool calling.provider: 'cloud')When the user picks the official Cloud API, keep the same layered structure but apply these deltas (everything else — handlers, features, commands, storage for the message store — is identical):
src/client.ts constructs new Client({ provider: 'cloud', cloud: { accessToken, phoneNumberId, wabaId, verifyToken, appSecret } }) from env. No auth-store QR/pairing; store (message store) still applies.qr/pairing-code handler. The connection handler only logs connect/disconnect. Inbound arrives via a webhook, not the socket.src/index.ts:
app/api/whatsapp/route.ts exporting GET = POST = wa.webhook().wa.webhook() (raw body on Express). Copy the exact mount from recipes.md recipe 16..env.example carries WA_TOKEN, WA_PHONE_ID, WA_WABA_ID, WA_VERIFY, WA_APP_SECRET (not SESSION_ID/auth paths).wa.sendTemplate(); never scaffold group/newsletter/poll/AIRich features on cloud (they throw UNSUPPORTED_ON_CLOUD); subscribe the messages field; keep the raw webhook body.Full cloud surface + mounts → references/cloud.md. Generate the webhook echo + template recipes verbatim from there. Everything below is the unofficial skeleton.
src/
├─ index.ts entry: build client → registerHandlers → (registerCommands) → shutdown hooks
├─ client.ts createClient(): Client factory; wires auth/store/authType/commandPrefix
├─ config/
│ └─ env.ts typed env accessor: env(key, fallback?) + required-var checks
├─ handlers/
│ ├─ index.ts registerHandlers(client): calls every register* in this dir, synchronously
│ └─ connection.ts registerConnectionHandlers(client): qr / pairing-code / connect / disconnect
└─ lib/
└─ jid.ts digitsOf() + jid helpers
Root files (always): package.json, tsconfig.json, .env.example, .gitignore.
| Feature | Files added | Wiring |
|---|---|---|
echo (any text reply) | src/handlers/message.ts → registerMessageHandlers(client) | called by handlers/index.ts |
slash-commands | src/commands/index.ts → registerCommands(client); one src/commands/<name>.ts per command | index.ts calls registerCommands; set commandPrefix in client.ts |
interactive-buttons | src/handlers/buttons.ts → registerButtonHandlers(client); menu lives in a src/commands/menu.ts | handlers/index.ts |
broadcast | src/features/broadcast.ts → runBroadcast(client, …) | invoked from a command in src/commands/broadcast.ts |
ai-agent | src/services/<provider>.ts (client factory); src/features/prompt.ts (SYSTEM_PROMPT constant); src/features/ai-agent.ts (askAgent); src/features/memory.ts (working memory); src/features/tools.ts (defs + runner, if tool calling) | message.ts calls askAgent |
Rules: storage construction always lives inside client.ts (not a new folder). Pure, side-effect-free
helpers go in src/lib/. If a use case needs a new external integration (HTTP API, DB, queue), it is a
new src/services/<name>.ts; its business logic is a new src/features/<name>.ts. Keep this mapping —
that is what makes output reproducible.
index.ts does: const client = createClient(); registerHandlers(client)
with registerHandlers/registerCommands being synchronous functions (they call client.on(...)
directly). autoConnect defaults true and schedules connect() in a microtask, so all top-level
sync wiring in index.ts runs first. Never await before attaching listeners.send()/broadcast() throw
ZaileysBuilderError ("client not connected") before the socket opens.await:
await client.send(jid).text('hi'). Awaiting yields a WAMessageKey (key.id).config/env.ts — never read process.env elsewhere; never hardcode tokens/URLs/numbers.src/features/prompt.ts), NEVER an env var or an env(key, '…long default…') fallback.[email protected], group [email protected]. Use lib/jid.ts to strip to digits before comparing.phoneNumber (E.164 digits, no +) or connect() rejects.rateLimitPerSec, default 5) and called inside a handler..env and .zaileys/ are secrets — always gitignore them.client.ts)| Choice | Peer dep | Construction |
|---|---|---|
file (default) | none | omit auth, or auth: new FileAuthStore({ basePath: './.zaileys/auth/bot' }) |
memory | none | auth: new MemoryAuthStore(), store: new MemoryMessageStore() (no persistence) |
sqlite | better-sqlite3 | auth: new SqliteAuthStore({ database }), store: new SqliteMessageStore({ database }) |
postgres | pg | auth: new PostgresAuthStore({ connectionString }), store: new PostgresMessageStore({ connectionString }) |
redis | redis | auth: new RedisAuthStore({ url, namespace: 'wa-auth' }), store: new RedisMessageStore({ url, namespace: 'wa-store' }) |
convex | convex | auth: new ConvexAuthStore({ url, namespace: 'wa-auth' }), store: new ConvexMessageStore({ url, namespace: 'wa-store' }) |
Auth and message store are independent. Default store is MemoryMessageStore (RAM only). Only Convex
persists scheduled jobs. Give Redis/Convex auth + store distinct namespaces.
Generate every skeleton file + one block per chosen feature. Trim feature blocks the spec did not request.
src/config/env.ts (skeleton — always)import 'dotenv/config'
export const env = (key: string, fallback?: string): string => {
const value = process.env[key] ?? fallback
if (value === undefined) throw new Error(`Missing env: ${key}`)
return value
}
export const envNumber = (key: string, fallback: number): number => {
const raw = process.env[key]
return raw === undefined ? fallback : Number(raw)
}
src/lib/jid.ts (skeleton — always)export const digitsOf = (jid: string): string => (jid.split(/[:@]/)[0] ?? '').replace(/\D/g, '')
src/client.ts (skeleton — auth + storage wired here)import { Client } from 'zaileys'
import { env } from './config/env.js'
export const createClient = (): Client =>
new Client({
sessionId: process.env['SESSION_ID'] ?? 'bot',
// authType: 'pairing', phoneNumber: env('PHONE_NUMBER'), // pairing variant
// commandPrefix: ['/', '!'], // add iff slash-commands
// auth/store: construct per STORAGE MATRIX, reading URLs via env()
})
Pairing + Postgres example body (swap per choices):
import { Client, PostgresAuthStore, PostgresMessageStore } from 'zaileys'
import { env } from './config/env.js'
export const createClient = (): Client => {
const connectionString = env('DATABASE_URL')
return new Client({
sessionId: process.env['SESSION_ID'] ?? 'bot',
authType: 'pairing',
phoneNumber: env('PHONE_NUMBER'),
commandPrefix: ['/', '!'],
auth: new PostgresAuthStore({ connectionString }),
store: new PostgresMessageStore({ connectionString }),
})
}
src/handlers/connection.ts (skeleton — always)import type { Client } from 'zaileys'
export const registerConnectionHandlers = (client: Client): void => {
client.on('qr', ({ qrString }) => console.log('Scan QR:', qrString))
client.on('pairing-code', ({ code }) => console.log('Pairing code:', code))
client.on('connect', ({ me }) => console.log('Connected as', me.id))
client.on('disconnect', ({ reason, willReconnect }) => {
console.log('Disconnected:', reason, willReconnect ? '(reconnecting)' : '')
})
}
src/handlers/index.ts (skeleton — always; lists every handler module)import type { Client } from 'zaileys'
import { registerConnectionHandlers } from './connection.js'
import { registerMessageHandlers } from './message.js'
// import { registerButtonHandlers } from './buttons.js' // iff interactive-buttons
export const registerHandlers = (client: Client): void => {
registerConnectionHandlers(client)
registerMessageHandlers(client)
// registerButtonHandlers(client)
}
src/index.ts (skeleton — the only file that runs)import { createClient } from './client.js'
import { registerHandlers } from './handlers/index.js'
// import { registerCommands } from './commands/index.js' // iff slash-commands
const client = createClient()
registerHandlers(client)
// registerCommands(client)
const shutdown = async (sig: string): Promise<void> => {
console.log(`\n${sig} — shutting down`)
try {
await client.disconnect()
} finally {
process.exit(0)
}
}
process.on('SIGINT', () => void shutdown('SIGINT'))
process.on('SIGTERM', () => void shutdown('SIGTERM'))
echo — src/handlers/message.tsclient.on('message', …) is the umbrella inbound event — one handler fires for every inbound
message type (text/image/video/audio/document/sticker/…). Prefer it over wiring many per-type handlers
when the bot reacts to all messages the same way; use the per-type events (text, image, …) only when
behavior truly differs by type. The callback context carries ctx.staticId — a stable hash of
room+sender — use it as the per-conversation key for memory/state instead of building your own.
import type { Client } from 'zaileys'
export const registerMessageHandlers = (client: Client): void => {
client.on('message', async (msg) => {
if (msg.isFromMe || !msg.text) return
await msg.react('👀')
await msg.reply(`Echo: ${msg.text}`)
})
}
ai-agent — message handler + service + feature modulessrc/services/openai.ts (provider client; swap import/SDK for Anthropic/Gemini/OpenAI-compatible):
import OpenAI from 'openai'
import { env } from '../config/env.js'
export const openai = new OpenAI({
apiKey: env('OPENAI_API_KEY'),
baseURL: process.env['OPENAI_BASE_URL'],
})
export const AI_MODEL = process.env['OPENAI_MODEL'] ?? 'gpt-4o-mini'
src/features/prompt.ts (persona — a code constant, NOT env; edit here to change the bot's behavior):
export const SYSTEM_PROMPT = `You are a concise, friendly WhatsApp assistant. Use a tool when you need real-time data.`
src/features/memory.ts (working memory — last N turns per chat; full archive lives in the message store):
import { envNumber } from '../config/env.js'
export type Turn = { role: 'user' | 'assistant'; content: string }
const MAX_HISTORY = envNumber('MAX_HISTORY', 16)
const store = new Map<string, Turn[]>()
export const history = (convoId: string): Turn[] => store.get(convoId) ?? []
export const remember = (convoId: string, turn: Turn): void => {
const log = store.get(convoId) ?? []
log.push(turn)
if (log.length > MAX_HISTORY) log.splice(0, log.length - MAX_HISTORY)
store.set(convoId, log)
}
export const reset = (convoId: string): void => void store.delete(convoId)
src/features/tools.ts (tool defs + runner; include only if tool calling chosen):
import type { ChatCompletionTool } from 'openai/resources/chat/completions'
export const tools: ChatCompletionTool[] = [
{
type: 'function',
function: {
name: 'get_current_time',
description: 'Current date/time. Use when asked about time or date.',
parameters: {
type: 'object',
properties: { timezone: { type: 'string', description: 'IANA tz, default Asia/Jakarta' } },
},
},
},
]
export const runTool = async (name: string, args: Record<string, unknown>): Promise<unknown> => {
switch (name) {
case 'get_current_time': {
const timezone = typeof args['timezone'] === 'string' ? args['timezone'] : 'Asia/Jakarta'
return { timezone, now: new Intl.DateTimeFormat('id-ID', { dateStyle: 'full', timeStyle: 'long', timeZone: timezone }).format(new Date()) }
}
default:
return { error: `unknown tool: ${name}` }
}
}
src/features/ai-agent.ts (the agent loop):
import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions'
import { AI_MODEL, openai } from '../services/openai.js'
import { history } from './memory.js'
import { SYSTEM_PROMPT } from './prompt.js'
import { runTool, tools } from './tools.js'
const MAX_TOOL_ROUNDS = 5
export const askAgent = async (convoId: string, userText: string): Promise<string> => {
const messages: ChatCompletionMessageParam[] = [
{ role: 'system', content: SYSTEM_PROMPT },
...history(convoId),
{ role: 'user', content: userText },
]
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
const res = await openai.chat.completions.create({ model: AI_MODEL, messages, tools, tool_choice: 'auto' })
const choice = res.choices[0]?.message
if (!choice) return 'No response from model.'
messages.push(choice)
const calls = choice.tool_calls ?? []
if (calls.length === 0) return choice.content ?? '(empty)'
for (const call of calls) {
if (call.type !== 'function') continue
let parsed: Record<string, unknown> = {}
try { parsed = JSON.parse(call.function.arguments || '{}') } catch { parsed = {} }
const output = await runTool(call.function.name, parsed)
messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(output) })
}
}
return 'Too many tool steps; try a simpler question.'
}
src/handlers/message.ts (ai-agent variant — replaces the echo version):
import type { Client } from 'zaileys'
import { askAgent } from '../features/ai-agent.js'
import { remember, reset } from '../features/memory.js'
export const registerMessageHandlers = (client: Client): void => {
client.on('message', async (msg) => {
if (msg.isFromMe || !msg.text) return
const convoId = msg.staticId
const room = msg.roomId ?? msg.senderId
const input = msg.text.trim()
if (input.toLowerCase() === '/reset') {
reset(convoId)
await msg.reply('Memory cleared. 🧹')
return
}
if (!input) return
try {
await msg.react('🤖')
await client.presence.typing(room)
const answer = await askAgent(convoId, input)
remember(convoId, { role: 'user', content: input })
remember(convoId, { role: 'assistant', content: answer })
await msg.reply(answer)
} catch (err) {
console.error('Agent error:', err)
await msg.reply('Sorry, something went wrong. Try again.')
}
})
}
slash-commands — src/commands/src/commands/index.ts:
import type { Client, Middleware } from 'zaileys'
import { registerPing } from './ping.js'
import { registerHelp } from './help.js'
const logging: Middleware = async (ctx, next) => {
console.log(`[cmd] ${ctx.command} from ${ctx.senderId} args=${ctx.args.join(',')}`)
await next()
}
export const registerCommands = (client: Client): void => {
client.use(logging)
registerPing(client)
registerHelp(client)
}
src/commands/ping.ts (one file per command — same shape for every command):
import type { Client } from 'zaileys'
export const registerPing = (client: Client): void => {
client.command('ping', async (ctx) => {
await ctx.reply('pong')
})
}
interactive-buttons — src/commands/menu.ts + src/handlers/buttons.tssrc/commands/menu.ts:
import type { Client } from 'zaileys'
export const registerMenu = (client: Client): void => {
client.command('menu', async (ctx) => {
await client.send(ctx.roomId ?? ctx.senderId).buttons(
[
{ id: 'yes', text: 'Yes' },
{ id: 'no', text: 'No' },
{ type: 'url', text: 'Docs', url: 'https://github.com/zeative/zaileys' },
],
{ title: 'Menu', text: 'Pick one', footer: 'zaileys' },
)
})
}
src/handlers/buttons.ts:
import type { Client } from 'zaileys'
const actions: Record<string, (jid: string, client: Client) => Promise<unknown>> = {
yes: (jid, client) => client.send(jid).text('You said yes!'),
no: (jid, client) => client.send(jid).text('Maybe next time.'),
}
export const registerButtonHandlers = (client: Client): void => {
client.on('button-click', async (ctx) => {
await actions[ctx.buttonId]?.(ctx.sender.jid, client)
})
}
broadcast — src/features/broadcast.ts + src/commands/broadcast.tssrc/features/broadcast.ts:
import type { Client } from 'zaileys'
export const runBroadcast = (client: Client, recipients: string[], text: string) =>
client.broadcast(recipients, (b) => b.text(text), {
rateLimitPerSec: 5,
onProgress: (done, total, jid, ok) => console.log(`[${done}/${total}] ${jid} ${ok ? 'ok' : 'fail'}`),
})
src/commands/broadcast.ts:
import type { Client } from 'zaileys'
import { env } from '../config/env.js'
import { runBroadcast } from '../features/broadcast.js'
import { digitsOf } from '../lib/jid.js'
export const registerBroadcast = (client: Client): void => {
client.command('broadcast', async (ctx) => {
const owner = digitsOf(env('OWNER', ''))
if (!owner || digitsOf(ctx.senderId) !== owner) return
const text = ctx.args.join(' ') || 'Announcement from zaileys.'
const recipients = (process.env['BROADCAST_TO'] ?? '').split(',').map((s) => s.trim()).filter(Boolean)
const result = await runBroadcast(client, recipients, text)
await ctx.reply(`Sent ${result.sent.length}, failed ${result.failed.length}`)
})
}
package.json{
"name": "zaileys-bot",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": { "node": ">=20.0.0" },
"scripts": {
"start": "tsx src/index.ts",
"dev": "tsx --watch src/index.ts"
},
"dependencies": {
"dotenv": "^16.4.0",
"zaileys": "^4.0.0"
},
"devDependencies": {
"tsx": "^4.21.0",
"@types/node": "^24.0.0",
"typescript": "^5.6.0"
}
}
Add chosen peer deps to dependencies (types to devDependencies where relevant): storage —
better-sqlite3 (+@types/better-sqlite3), pg (+@types/pg), redis, convex; ai-agent —
openai (or @anthropic-ai/sdk, @google/genai).
tsconfig.json{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src"]
}
.env.example# Session name → auth folder ./.zaileys/auth/<SESSION_ID>
SESSION_ID=bot
# Pairing auth only (E.164 digits, no '+'); omit for QR
# PHONE_NUMBER=6281234567890
# Storage URLs (only for the adapter you chose)
# DATABASE_URL=postgres://user:pass@localhost:5432/zaileys
# REDIS_URL=redis://localhost:6379
# CONVEX_URL=https://your.convex.cloud
# AI agent (ai-agent use case)
# OPENAI_API_KEY=sk-xxxx
# OPENAI_MODEL=gpt-4o-mini
# OPENAI_BASE_URL=http://localhost:11434/v1
# MAX_HISTORY=16
# (persona/system prompt lives in code: src/features/prompt.ts — not env)
# Broadcast guard
# OWNER=6281234567890
# [email protected],[email protected]
.gitignorenode_modules
.zaileys
.env
*.db
*.log
npm install # add peer deps for chosen storage / ai provider
cp .env.example .env # then edit values
npm start # runs `tsx src/index.ts`
Then on the phone: WhatsApp → Settings → Linked Devices → Link a Device. For QR, scan the printed
code. For pairing, choose Link with phone number instead and type the printed 8-char code. The
session persists (under ./.zaileys/auth/<SESSION_ID>/ for file auth, or in the DB for db adapters),
so later runs skip auth. To force a fresh login, delete the session.
src/index.ts, src/client.ts, src/config/env.ts,
src/handlers/index.ts, src/handlers/connection.ts, src/lib/jid.ts) — even for trivial bots.src/index.ts has top-level execution; every other module exports functions, no import-time side effects.handlers/index.ts and commands/index.ts import every register* that was generated.send()/reply() builder.commandPrefix in client.ts iff slash-commands; pairing-code handler + phoneNumber iff pairing.client.ts), peer dep, and env var all line up.config/env.ts; .env/.zaileys gitignored.Generated bots can use the full current API — don't hand-roll what the library provides:
client.on(name, …)): message (umbrella — all types), text, image, video,
audio, document, sticker, reaction, edit, delete, poll-vote, button-click,
list-select, mention, mention-all, group-update, group-join, group-leave, member-tag,
call-incoming, call-ended, history-sync, presence, newsletter; connection: connect,
disconnect, qr, pairing-code, reconnecting, auth-exhausted, error.ctx.staticId (stable hash(roomId|senderId)) — use it to key per-conversation
memory/state, not an ad-hoc roomId ?? senderId. For send targets still use roomId/senderId JIDs.send()): text, image, video, videoNote, audio,
document, sticker, buttons, carousel, list, poll, template, location, contact,
event, groupInvite, product, requestPhoneNumber, sharePhoneNumber, limitSharing, album.
Modifiers: reply, mentions, mentionAll, disappearing, to. Notes: event() won't render in
1:1 DMs (group/community only); product() needs a Business account (businessOwnerId);
groupInvite() expiresAt is unix SECONDS.client.profile
(setName/setStatus/setPicture/removePicture/getPicture/getStatus), client.chat
(archive/unarchive/pin/unpin/mute/unmute/markRead/markUnread/star/unstar/delete/clear),
client.contact (check/exists/save/remove), client.business
(profile/catalog/collections/orderDetails/createProduct/updateProduct/deleteProduct).WAMessageKey): client.pin(key, { duration? }), client.unpin(key),
plus client.setDisappearing(to, seconds) to toggle disappearing messages for a chat.references/recipes.md; full API surface: references/api.md (in the
assist skill).zaileys: ZaileysBuilderError, ZaileysCommandError, ZaileysDomainError,
ZaileysAutomationError, ZaileysStoreError.These are authoritative and kept in sync with the code — fetch them when you need more detail, the newest API, or to verify before answering (do not guess when unsure):
/getting-started · /installation · /configuration · /client · /events · /sending-messages · /media · /interactive · /rich-responses · /commands · /automation · /storage · /error-handling · /runtimes · /troubleshooting · /api-reference (e.g. https://zeative.github.io/zaileys/sending-messages)npx claudepluginhub zeative/zaileys --plugin zaileys-officialOrchestrates any zaileys task — scaffold, debug, review, or add features for this type-safe WhatsApp framework running on Baileys or the Cloud API.
Integrates with WhatsApp Business Cloud API (Meta) for sending messages, managing templates, and verifying webhooks via HMAC-SHA256. Includes Node.js and Python boilerplates for automation.
Integrates with WhatsApp Business Cloud API (Meta) for sending/receiving messages, managing templates, verifying webhooks with HMAC-SHA256, and building automated chatbots. Includes Node.js and Python boilerplates.