Imported from VeryardDev/Baileys (
AGENTS.md). Install upstream withnpx skills add VeryardDev/Baileys. Copyright stays with the author.
AGENTS.md
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.
What Baileys is
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.
Repository layout
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.
Setup
This repo requires Yarn 4 via Corepack. Yarn 1 / classic will fail noisily on package.json resolutions.
corepack enable
yarn install
Node ≥ 20 (enforced by engines and preinstall).
Daily commands
| 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.
Code style
- 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.
Idiomatic patterns
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.
Errors — Boom with statusCode
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.
Logging — structured, never positional
// 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.
JIDs — always go through the helpers
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 only
Never 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.
Binary nodes — typed accessors
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.
Sending IQs — query with timeouts
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.
Listening for incoming stanzas — CB: prefix
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.
Public events — ev.on, never call twice
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).
Async cleanup — bracket pattern
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.
Imports — verbatimModuleSyntax
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.
Optional chains over null guards
// 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.
Tests — colocate, name by behavior
// 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').
Public API discipline
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.
Commits and PRs
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.
What not to touch without coordination
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.
Testing expectations
- 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.
Security-sensitive changes
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).
AI agent etiquette
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.