Chatpack
Core Concepts

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 nothingpersists the message unchanged
return { body } / { metadata }persists (and broadcasts) the rewritten version
throwstores 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_INPUT error, not a silent drop - rejecting must be explicit.
  • On edits, only a returned body applies; metadata rewrites are ignored (edits never change metadata).

Two fields exist so content rules can see what the message is doing, not just what it says:

  • mentions is 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.
  • forwardedFrom is null on an ordinary send and the source's three ids on a forward. A forward runs this hook with action: "send" - it is a send, so every filter keeps applying without changes - and branching on forwardedFrom is 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).

actionMeaningInternal event
sendNew message persistedmessage.created
editMessage body updatedmessage.updated
deleteMessage soft-deletedmessage.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.)

On this page