Guide for AI coding agents (Claude Code, Cursor, Aider, Codex, Copilot Workspace, etc.) contributing to Baileys. Human contributors should also read this — there's nothing AI-specific in the conventions, only in the disclosure rules at the end.
If you are an AI agent driving this repo, read this file first, then CODE_OF_CONDUCT.md (specifically the AI policy section), then SECURITY.md.
A TypeScript WebSocket client for the WhatsApp Web protocol. No browser, no Selenium — it speaks the binary Noise/protobuf protocol directly. Used by thousands of downstream projects, so changes to public APIs and wire-level handling have wide blast radius.
The library is dual-use: legitimate automation, bots, and integrations on one side; spam, stalkerware, and ToS-breaking automation on the other. We do not accept contributions whose primary purpose is to enable abuse (mass messaging, evasion of WhatsApp's anti-spam, scraping users without consent). See CODE_OF_CONDUCT.md.
src/
Socket/ High-level socket — chats, groups, messages send/recv, newsletter, USync
Signal/ Signal Protocol session/sender-key wrapping over libsignal-node
Utils/ Decoding, media, auth state, retry, app-state sync, generics
Types/ Public TypeScript types — touching these is a public-API change
WABinary/ Binary node encoding/decoding
WAUSync/ USync query protocols
Defaults/ Constants (WA Web version, baileys version, default config)
__tests__/ Jest unit + integration tests; e2e tests live in __tests__/e2e
WAProto/ Generated protobuf bindings — DO NOT hand-edit
Example/ Reference implementation in example.ts
proto-extract/ Tooling to refresh protobufs from WA Web
scripts/ Repo automation (version bumps, etc.)
.github/workflows/ CI — lint, test, e2e, build, release
WAProto/index.js, WAProto/index.d.ts are generated by WAProto/GenerateStatics.sh. If a change requires modifying them, regenerate via npm run gen:protobuf rather than editing by hand.
This repo requires Yarn 4 via Corepack. Yarn 1 / classic will fail noisily on package.json resolutions.
corepack enable
yarn installNode ≥ 20 (enforced by engines and preinstall).
| Task | Command |
|---|---|
| Install | yarn install |
| Build (lib + types) | yarn build |
| Type-check + lint | yarn lint |
| Auto-fix lint + format | yarn lint:fix |
| Format only | yarn format |
| Unit + integration tests | yarn test |
| End-to-end tests | yarn test:e2e (requires the bartender mock server, see below) |
| Run the example | yarn example |
| Regenerate protobufs | yarn gen:protobuf |
| Audit deps | yarn npm audit --recursive |
yarn lint runs tsc first, then ESLint. A green lint means no type errors.
- TypeScript strict —
strict,strictNullChecks,noUncheckedIndexedAccess,verbatimModuleSyntaxare all on. Don't disable them locally. - Tabs for indentation, single quotes, no semicolons (Prettier-enforced).
- No
anyin new code. Existinganys in tests are tolerated as warnings, not invitations. - No comments that restate the code. Comment the why — protocol quirks, WhatsApp-side behavior, non-obvious workarounds. Don't comment-narrate "// loop over messages".
- No emojis in code or commit messages unless the user explicitly asks.
- Named exports preferred. Default exports only where they already exist (e.g.,
makeWASocket). - Errors: throw
Boom(@hapi/boom) for protocol/HTTP-style errors so downstream code can branch on.output.statusCode. PlainErrorfor everything else. - Logging: every code path that crosses an async boundary should accept a
logger: ILogger(pino-compatible). Don'tconsole.log.
These are the patterns the existing code uses. Match them. New code that does the same thing differently will get review comments asking you to align.
import { Boom } from '@hapi/boom'
if (!sock.user) {
throw new Boom('Not authenticated', { statusCode: 401 })
}
if (!isJidUser(jid)) {
throw new Boom(`Invalid jid: ${jid}`, { statusCode: 400 })
}Downstream code branches on error.output.statusCode to retry, log out, or surface to the user. Plain throw new Error(...) loses that signal. Reserve plain Error for genuinely internal invariants where no caller is expected to recover.
// good: object first, message last; reads cleanly in JSON logs
logger.warn({ msgId: attrs.id, from: attrs.from }, 'error 463: account restricted')
logger.debug({ messageKey }, 'already requested resend')
logger.error({ err: error, opName }, 'failed to parse mex notification JSON')
// bad: string interpolation, untyped fields
logger.warn(`error 463 from ${attrs.from}`)
console.log('failed:', error)Pino convention: errors go under err, not error or e. The structured object is the first argument so log processors can index it.
import { jidDecode, jidNormalizedUser, areJidsSameUser, isJidUser } from '../WABinary'
const decoded = jidDecode(rawJid) // { user, server, device?, agent? } | undefined
const normalized = jidNormalizedUser(rawJid) // strips device/agent, lowercases
const same = areJidsSameUser(a, b) // compare user portions onlyNever split a JID with .split('@') or compare with ===. JIDs carry device suffixes (:0, :42), agent fields, and LID/PN duality — string ops will silently miss matches and you'll ship a bug that only fires on multi-device accounts.
import { getBinaryNodeChild, getBinaryNodeChildren, getBinaryNodeChildString } from '../WABinary'
const groupsNode = getBinaryNodeChild(result, 'groups')
if (!groupsNode) {
throw new Boom('missing <groups> in iq response', { statusCode: 502 })
}
const groups = getBinaryNodeChildren(groupsNode, 'group') // BinaryNode[]
const text = getBinaryNodeChildString(node, 'body') // string | undefined
const { attrs } = node // typed Record<string, string>Don't reach into node.content as an array directly — types are loose and the shape varies by stanza. The accessors handle the missing/single/array cases.
const result = await sock.query({
tag: 'iq',
attrs: { to: S_WHATSAPP_NET, type: 'get', xmlns: 'w:profile:picture' },
content: [{ tag: 'picture', attrs: { type: 'image', query: 'url' } }]
}, /* timeoutMs */ 15_000)query auto-generates the stanza id, attaches a one-shot listener, and rejects on timeout. Don't write your own sock.ws.send + manual listener — you'll leak listeners on errors.
sock.ws.on('CB:ib,,dirty', async (node: BinaryNode) => {
const { attrs } = getBinaryNodeChild(node, 'dirty')!
// ...
})
sock.ws.on('CB:notification,type:server_sync', handler)The CB:tag,attr:value syntax routes by tag + attribute filter inside the websocket. This is how messages-recv, chats, groups listen — don't filter manually inside a generic 'message' handler.
sock.ev.on('messages.upsert', ({ messages, type }) => { ... })
sock.ev.on('connection.update', ({ connection, lastDisconnect }) => { ... })
sock.ev.on('creds.update', saveCreds)saveCreds (or any handler) must be deduped — registering twice means writing twice. The harness in __tests__/e2e/helpers/test-client.ts shows the cleanup pattern (ev.off in teardown).
When you allocate a resource (timer, listener, ws subscription) inside a Promise, clean it up in both paths:
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
cleanup()
reject(new Boom('timed out', { statusCode: 408 }))
}, timeoutMs)
const cleanup = () => {
clearTimeout(timer)
sock.ev.off('event.name', handler)
}
const handler = (data: T) => {
if (matches(data)) {
cleanup()
resolve(data)
}
}
sock.ev.on('event.name', handler)
})Half-cleanups are how this codebase grew its memory leaks. The bracket pattern (allocate → cleanup defined → both paths call it) is the fix.
import type { WAMessage, WAUrlInfo } from '../Types'
import { Boom } from '@hapi/boom'
import { type BinaryNode, getBinaryNodeChild } from '../WABinary'Type-only imports must be marked import type or inline-prefixed type — TS strict-mode verbatimModuleSyntax will fail the build otherwise. Don't merge a value import with a type import unless you actually use both at runtime.
// good
const text = msg.message?.extendedTextMessage?.text ?? msg.message?.conversation
if (!sent?.key.id) return
// bad — proliferates `if (x && x.y && x.y.z)` ladders
if (msg.message && msg.message.extendedTextMessage) { ... }noUncheckedIndexedAccess is on, so array/record indexing returns T | undefined. Don't paper over it with ! unless you have an invariant the type system can't see — and if you do, leave a one-line comment explaining the invariant.
// src/__tests__/Utils/decode-wa-message.test.ts
describe('SERVER_ERROR_CODES', () => {
it('MessageAccountRestriction is 463', () => {
expect(SERVER_ERROR_CODES.MessageAccountRestriction).toBe('463')
})
})Test files mirror the source path: src/Foo/bar.ts → src/__tests__/Foo/bar.test.ts. describe names the unit, it names the behavior in plain English. Avoid it('works').
src/Types/** and the top-level src/index.ts re-exports define the public surface. Treat changes there as breaking unless you can prove additive-only.
For wire-level changes (Socket/messages-recv.ts, Utils/decode-wa-message.ts, WABinary/, etc.) — describe the WhatsApp-side trigger in the PR. Reviewers can't always reproduce protocol behavior, so the description does the heavy lifting.
Conventional commits, scoped where useful:
feat(socket): add support for reachout limits XWAs
fix(retry): process <keys> bundle and embed SKDM on resend
chore(deps): bump ajv from 6.12.6 to 6.15.0
test(e2e): test harness + signal/prekey fixes
PR titles follow the same convention. Squash-merge is the default.
Before opening a PR:
yarn lint— must be 0 errors.yarn test— must be all green. If you can't run e2e locally, say so in the PR description.- Don't commit
baileys_auth_info/,.env,mitm_*.db,.superset/, or any session state. Git is configured to ignore the obvious ones; double-check. - Don't commit regenerated
yarn.lockfrom a different package manager. If youryarn.lockdiff is unexpectedly large (thousands of lines for a one-linepackage.jsonchange), you're using Yarn 1 — switch to Corepack.
libsignalcryptographic flows — session state, prekeys, sender-key derivation. Subtle bugs here are silent and brick downstream sessions.Defaults/baileys-version.json— bumped by theupdate-versionworkflow. Manual edits race with automation.WAProto/generated files — regenerate, don't hand-edit..github/workflows/— CI changes are reviewed separately; bundle them in their own PR when possible.package.jsonresolutions— these patch known security advisories. Removing entries reintroduces CVEs; checkyarn npm audit --recursivebefore pruning.
- New public API → unit test in
src/__tests__/. - New protocol path or stanza handler → integration test mocking the binary node, not an e2e test (e2e is expensive and flaky in agent loops).
- New crypto/auth flow → e2e against bartender if feasible, otherwise a deterministic fixture-based unit test.
Tests are colocated by area: src/__tests__/Socket/, src/__tests__/Utils/, src/__tests__/binary/. Match the existing layout.
If your change touches:
- Auth state read/write, key storage, prekey/session lifecycle
- Message decryption / signature verification
- Any path that handles user PII (phone numbers, JIDs, message content) in logs or errors
…flag it explicitly in the PR description. See SECURITY.md for disclosure of vulnerabilities (do not file them as public issues).
Beyond the AI policy in CODE_OF_CONDUCT.md:
- Read before you write. This codebase has subtle protocol invariants. A grep-and-replace agent will make a mess of
Socket/messages-recv.ts. Read the surrounding handler before editing. - Don't invent WhatsApp protocol details. If you're not sure how a stanza is structured, find a real example in the tests or in
Utils/decode-wa-message.ts. Hallucinated XML attributes get merged and then break in production. - Stay in scope. A bug fix doesn't need a refactor pass. A type tweak doesn't need a comment cleanup PR.
- Don't paste auth state into AI tools.
baileys_auth_info/contains long-lived Signal keys. Treat it like an SSH private key. - Disclose AI authorship in PRs. A one-line "drafted with [tool], reviewed by [human]" is enough. See
CODE_OF_CONDUCT.md§ AI Policy for the full rule.
- Discord: https://discord.gg/WeJM5FP9GG
- Wiki: https://baileys.wiki
- Security: see
SECURITY.md
If you're an agent and you're stuck on something this file doesn't cover, fall back to reading the relevant src/ directory and the matching tests — they're the source of truth.