Hooks reference
Find the plugin hook to use for lifecycle events, auth, messages, interceptors, routes, pages, tools, and cleanup.
Hooks connect your plugin to the open-wa host. Your plugin's init() function returns the handlers and features it wants to use.
All hooks are optional. Use only the hook your task needs.
Lifecycle hooks
core.starting
Fires when the session is starting up.
'core.starting': async ({ config }) => {
// config is the global wa.config.js contents
logger.info('Session starting');
}Payload: { config: unknown }, the global configuration object.
core.started
Fires after the session has fully started.
'core.started': async () => {
logger.info('Session ready');
// Start one-time setup here
}core.stopping
Fires when the session is shutting down.
'core.stopping': async ({ reason }) => {
logger.info('Session stopping', { reason });
// Flush queues, save state
}Payload: { reason?: string }, why the session is stopping.
client.ready
Fires when the WhatsApp client is connected and authenticated.
'client.ready': async ({ sessionId }) => {
logger.info('WhatsApp connected', { sessionId });
}Payload: { sessionId: string }, the current session identifier.
Auth hooks
auth.qr
Fires when a QR code is emitted.
'auth.qr': async ({ details }) => {
const qr = details?.qr;
const attempt = details?.attemptInThisCycle;
logger.info('QR code emitted', { attempt });
// Send the QR to your own display channel
}Payload:
details.qr, the QR code stringdetails.attemptInThisCycle, the QR emission count for this cycle, when provided
The current host delivers this hook from its internal QR event, so the payload also contains the event envelope (correlationId, ts, and step). It does not include a sessionId field.
auth.authenticated
Fires after successful authentication.
'auth.authenticated': async ({ details }) => {
if (!details?.isAuthenticated) return;
logger.info('Authenticated', { method: details.method });
}Payload: an event envelope whose details object contains isAuthenticated and, when known, the authentication method. The hook does not receive a sessionId field.
Message hooks
message.received
Fires when a message is received.
function getTextMessage(message: unknown) {
if (!message || typeof message !== 'object') return null;
const candidate = message as { body?: unknown; from?: unknown; type?: unknown };
if (candidate.type !== 'chat') return null;
if (typeof candidate.body !== 'string' || typeof candidate.from !== 'string') {
return null;
}
return { body: candidate.body, from: candidate.from, type: 'chat' };
}
'message.received': async ({ message }) => {
const msg = getTextMessage(message);
if (!msg) return;
logger.info('Text message received', { from: msg.from });
}Payload: { message: unknown }. Examine the necessary fields before you use them.
To detect message type, check the type field:
'chat', text message'image', image'video', video'audio', voice note or audio message'document', document/file'sticker', sticker'location', location share
To detect group messages, check message.isGroupMsg or whether the from field ends with @g.us.
message.sent
This hook currently reports the message.any event. Inspect the payload if you need to distinguish sent messages from other message events.
'message.sent': async ({ message }) => {
logger.info('Message sent', { message });
}Payload: { ctx: EventContext; message: unknown }
message.ack
Fires when a message acknowledgment is received.
'message.ack': async ({ ack }) => {
logger.info('Message acknowledged', { ack });
}Payload: { ctx: EventContext; ack: unknown }, the event context and runtime acknowledgment value.
Message interceptors
message.send.before
The hook is declared in the SDK types, but the host does not call it when a message is sent. Returning it from init() does not intercept sends.
'message.send.before': async (
input: { to: string; content: unknown },
output: { content: unknown; metadata?: Record<string, unknown> }
) => {
// Modify outgoing text before it is sent
if (typeof output.content === 'string') {
output.content = output.content.replace(/badword/g, '****');
}
}The shown shape documents the declared type only. Use an explicit wrapper around your own client.sendText() calls when you need outbound filtering until host support is added.
message.send.after
The hook is declared in the SDK types, but the host does not call it after a message is sent. Returning it from init() does not observe sends.
'message.send.after': async (
input: { messageId: string; to: string },
output: { metadata?: Record<string, unknown> }
) => {
output.metadata = { sentAt: Date.now() };
}The shown shape documents the declared type only. Use a wrapper around your own send calls when you need post-send bookkeeping.
API routes
routes
Return a Hono sub-app that will be mounted at /plugins/<plugin-name>/.
import { Hono } from 'hono';
init: async () => ({
routes: () => {
const app = new Hono();
app.get('/status', (c) => {
return c.json({ plugin: 'my-plugin', status: 'ok' });
});
app.post('/webhook', async (c) => {
const body = await c.req.json();
// handle incoming webhook
return c.json({ ok: true, body });
});
return app;
},
})Routes are automatically mounted and accessible at /plugins/<plugin-name>/.
See HTTP routes in plugins for more details.
Dashboard pages
pages
Declare dashboard pages this plugin wants rendered in the dashboard sidebar.
init: async () => ({
pages: [{
path: '/',
title: 'Plugin Status',
icon: '📊',
order: 1,
description: 'Current plugin status and configuration',
}],
})Fields:
path, declared route segment; use/for the current dashboard entrytitle, display title in sidebaricon, emoji or Lucide icon nameorder, optional ordering metadata; the current dashboard does not sort by itdescription, optional metadata; the current dashboard does not display it
The dashboard uses these declarations for its manifest and sidebar, then opens the standard plugin status view. The hook does not add a custom plugin screen.
See Dashboard pages for more details.
AI tools
tool
Declare a tool for a host integration to connect to an agent. Keep its description narrow, validate arguments, and avoid actions the agent must not run. The current MCP endpoint does not expose plugin-declared tools; see AI Tools for that limitation.
init: async ({ client }) => ({
tool: {
sendWelcomeMessage: {
description: 'Send a welcome message to a contact',
args: {
chatId: z.string().describe('The chat ID to send to'),
name: z.string().describe('The contact name'),
},
execute: async (args, context) => {
if (typeof args.chatId !== 'string' || typeof args.name !== 'string') {
return 'chatId and name must be strings';
}
await client.sendText(
args.chatId,
`Welcome, ${args.name}!`
);
context.logger.info('Welcome message sent', { sessionId: context.sessionId });
return `Welcome message sent to ${args.chatId}`;
},
},
},
})Tool definition:
description, what the tool does (shown to AI agents)args, Zod schema for argumentsexecute, the implementation function
Tool context:
sessionId, current session IDlogger, scoped loggerabort, AbortSignal for cancellation
The tool context does not include client. Tools that need WhatsApp methods must close over client from init(), as shown above.
See AI tools for more details.
Cleanup
dispose
Called when the session is shutting down. Use this to clean up resources.
init: async ({ logger }) => ({
dispose: async () => {
// Close database connections
// Flush message queues
// Wait for in-flight requests
logger.info('Plugin disposed');
},
})Catch-all
event
Receives every public event. Use this when you need to react to events that do not have a specific hook.
init: async ({ logger }) => ({
event: async ({ event, payload }) => {
logger.debug('Event received', { event, payload });
},
})Input: { event: string; payload: unknown }
Use specific hooks when possible. They are easier to test and prevent catch-all work on every event.
Related
- Plugin getting started, build your first plugin
- PluginClient reference, available client methods
- PluginInput breakdown, what your plugin receives
Was this helpful?
Your answer includes the page path and docs version.
