Writing a Plugin
The ChatpackPlugin seam - extra routes, live signals, and hooks into stream lifecycle, mark-read, and delivery.
Plugins extend Chatpack with real-time behavior - extra HTTP routes and reactions to core actions - without touching storage. The first-party trio (typing, presence, receipts) is built on this exact seam; it's deliberately minimal.
The interface
import type { ChatpackPlugin } from "@chatpack/core";
export interface ChatpackPlugin {
/** Unique plugin name, used in logs. */
name: string;
/**
* Handle an HTTP request under basePath that no core route matched.
* Return a Response to answer it, or null to pass to the next plugin
* (and ultimately the 404).
*/
handleRequest?(ctx: PluginRequestContext): Promise<Response | null> | Response | null;
/** A user's SSE stream connected. Fire-and-forget. */
onStreamOpen?(ctx: PluginStreamContext): void;
/** A user's SSE stream disconnected. Fire-and-forget. */
onStreamClose?(ctx: PluginStreamContext): void;
/** A user durably updated their last-read state. Fire-and-forget. */
onMarkRead?(ctx: PluginMarkReadContext): void;
/**
* A durable event was delivered to a connected user's live stream.
* Fires once per connected stream (two tabs → twice) - treat derived
* signals as at-least-once.
*/
onEventDelivered?(ctx: PluginEventDeliveredContext): void;
}Every hook context extends PluginContext:
Prop
Type
Rules of the seam
- Notification hooks are fire-and-forget - a throwing plugin never breaks the request that triggered it (core wraps every hook in a try/catch and logs).
handleRequestis a real route handler: it runs after core routes miss and before the 404, with auth already resolved (ctx.userId). ThrownChatpackErrors map to normal JSON error responses. First plugin response wins.- Plugins own their event namespace - name events like
"myplugin.something".
Publishing ephemeral events
ctx.publishEphemeral({
type: "reaction.added", // namespaced event name
conversationId, // optional
senderId: ctx.userId, // the user the signal is about
recipientIds: [otherUserId], // who may receive it
payload: { messageId, emoji }, // event-specific data, default {}
});Recipients receive it as an SSE event named after type, with data:
{
"type": "reaction.added",
"ephemeral": true,
"conversationId": "…",
"senderId": "…",
"payload": { "…": "…" },
"at": "…"
}Example: a live reactions plugin
Ephemeral emoji reactions - a "someone reacted" flash while both users are
online (persisting reactions would be your app's job, via metadata or your
own table):
import type { ChatpackPlugin } from "@chatpack/core";
import { ChatpackError } from "@chatpack/core";
export function reactions(): ChatpackPlugin {
return {
name: "reactions",
async handleRequest(ctx) {
// POST /conversations/:id/reactions
const [root, conversationId, leaf] = ctx.segments;
if (ctx.method !== "POST" || root !== "conversations" || leaf !== "reactions") {
return null; // not ours - pass through
}
const { messageId, emoji } = await ctx.request.json();
if (typeof emoji !== "string" || typeof messageId !== "string") {
throw new ChatpackError("INVALID_INPUT", "emoji and messageId are required");
}
// permission check for free: throws FORBIDDEN_READ / CONVERSATION_NOT_FOUND
const conversation = await ctx.api.getConversation({
userId: ctx.userId,
conversationId,
});
ctx.publishEphemeral({
type: "reaction.added",
conversationId,
senderId: ctx.userId,
recipientIds: conversation.participants.map((p) => p.userId),
payload: { messageId, emoji },
});
return Response.json({ ok: true });
},
};
}Register it like the built-ins:
const chat = chatpack({ storage, auth, plugins: [typing(), reactions()] });Lifecycle hooks in practice
The built-in plugins show what each hook is for:
presence()usesonStreamOpen/onStreamClose- the SSE connection itself is the heartbeat.receipts()usesonEventDelivered(→receipt.deliveredto the sender) andonMarkRead(→receipt.readto the other participant).typing()uses onlyhandleRequest- completely stateless.
Their source is compact and readable:
packages/core/src/plugins/.
Custom transports
If you implement a custom Transport, note that publish/subscribe carry
TransportEvent = ChatEvent | EphemeralEvent - discriminate with the exported
isEphemeralEvent() guard. Rationale in
ADR 0008.