Message hooks
Block or rewrite messages before persistence, react after mutations succeed.
Permissions answer "may this user write here?" - they never see message content. Message hooks are where content rules live: length caps, profanity filters, spam checks, and post-send side-effects like triggering an AI reply.
Both hooks are optional and run after auth and permission checks. The
before hook handles sends and edits. The after hook handles every durable
message mutation - send, edit, or delete.
import { chatpack } from "@chatpack/core";
const chat = chatpack({
storage,
auth,
hooks: {
// Runs BEFORE the message is persisted.
beforeMessageSend: ({ user, conversation, body, action }) => {
if (body.length > 2000) {
throw new Error("Max 2000 characters."); // → 422 MESSAGE_REJECTED
}
return { body: censorProfanity(body) }; // persist the rewrite
// ...or return nothing to accept the message unchanged.
},
// Runs AFTER persistence and the internal live broadcast.
afterMessageMutation: async ({ action, message, recipientIds, mentions }) => {
if (action !== "send") return;
// recipientIds is everyone except the sender - one id in a DM, N in a group.
// mentions is the subset the message named, so a "mentions only" setting
// is a filter here, not a feature Chatpack has to own.
await enqueueMessageAlerts({
messageId: message.id,
recipientIds,
mentionRecipientIds: mentions,
});
},
},
});beforeMessageSend - the gate
Receives the sender, the conversation (with participantIds), the submitted
body, metadata, role, mentions, forwardedFrom, and action. Three
outcomes:
| You... | Chatpack does... |
|---|---|
| return nothing | persists the message unchanged |
return { body } / { metadata } | persists (and broadcasts) the rewritten version |
| throw | stores nothing, broadcasts nothing; the sender gets 422 MESSAGE_REJECTED |
A plain throw new Error("reason") becomes MESSAGE_REJECTED with your
message in the response body - no Chatpack imports needed. Throw a
ChatpackError to pick a different code instead.
Two guard rails:
- Rewriting to an empty body is an
INVALID_INPUTerror, not a silent drop - rejecting must be explicit. - On edits, only a returned
bodyapplies;metadatarewrites are ignored (edits never change metadata).
Two fields exist so content rules can see what the message is doing, not just what it says:
mentionsis the validated id set - participation is already checked, so a rule can cap how many people one message may name, or refuse a mention of an AI participant. You cannot rewrite it; return{ body }/{ metadata }only.forwardedFromisnullon an ordinary send and the source's three ids on a forward. A forward runs this hook withaction: "send"- it is a send, so every filter keeps applying without changes - and branching onforwardedFromis how you make something unforwardable:
beforeMessageSend: ({ body, forwardedFrom }) => {
if (forwardedFrom !== null && isConfidential(body)) {
throw new Error("This message can't be forwarded.");
}
};afterMessageMutation - post-persistence side-effects
Receives the message exactly as persisted (rewritten body, assigned id
and seq), the conversation, recipientIds, mentions, and the completed
action. It runs after the storage write and the internal live broadcast. It
cannot change the message. Chatpack catches hook errors, logs them, and still
returns a successful message response. Chatpack awaits the hook, so slow work
can delay that response.
recipientIds is every participant except the sender - one id in a DM, up
to 255 in a group, and empty when the sender is the only member (a creator-only
group). It's the field to notify from.
mentions sits next to it as the subset the message named - Chatpack stores
mentions and notifies nobody, so this is the only place a mention becomes a push.
It's reported on delete too, because the mention rows outlive the tombstone and
an integration may need to retract what it sent.
otherParticipantId is deprecated and removed at 1.0. It's single-valued, so in a group it
silently resolves to the first non-sender participant and everyone else gets no push. It's still
present and still populated so existing DM integrations keep working - migrate to recipientIds
(ADR 0017 §5).
action | Meaning | Internal event |
|---|---|---|
send | New message persisted | message.created |
edit | Message body updated | message.updated |
delete | Message soft-deleted | message.deleted |
Use it for side-effects: FCM, Web Push, queue the AI assistant reply,
notify analytics, or start moderation review. Filter by action when a
provider should receive only new-message notifications. For email or push,
recipientIds is the delivery audience; use mentions to apply mention
priority or mention-only preferences.
Chatpack always emits its internal live events. This hook does not replace or control UI broadcasts. Your application owns device tokens, users, provider calls, retries, and delivery guarantees.
afterMessageSend remains available as a deprecated compatibility hook. It
receives send and edit only, and carries the same recipientIds. Use
afterMessageMutation for new code.
Hooks are in-process functions, not webhooks. Chatpack catches hook errors, but it awaits the hook before finishing the request, so slow provider calls add message latency. Keep the hook fast. For reliable delivery, write jobs to your queue or outbox here, then let a worker check preferences and call your provider. See the app's hook and email and browser push delivery examples. (Scope rationale: ADR 0014.)