Chat mentions → webhook
A common product pattern: when the user is @-mentioned in chat, optionally relay the mention to an outbound integration (Slack, Discord, a webhook URL), then react to whether the relay succeeded — and let the user retry or disable the integration without writing it again. Triggery handles the chain of async work cleanly: one event in, one trigger drives the whole pipeline, side effects stay inside the components that own them.
Open in StackBlitz Open example on GitHubScenario
Section titled “Scenario”- The chat fires
chat:mentionedwith the message and the integration’s current state. - If the integration is on, the trigger calls a
postToWebhookaction (an async network call). - The webhook reactor fires a follow-up event (
chat:webhook-response) — success or error. - A second trigger handles the response: success → toast, error → toast + suggest disabling integration.
File layout
Section titled “File layout”Directorysrc/
Directorytriggers/
- mention.trigger.ts the relay rule
- webhook-response.trigger.ts the reaction to the result
Directoryfeatures/
Directorychat/
- ChatStream.tsx producer (
chat:mentioned)
- ChatStream.tsx producer (
Directoryintegration/
- IntegrationProvider.tsx provider (
integration) - WebhookReactor.tsx reactor (
postToWebhook→ fetch)
- IntegrationProvider.tsx provider (
Directorynotifications/
- MentionNotifier.tsx reactor (success / error toasts)
1. The mention trigger
Section titled “1. The mention trigger”import { createTrigger } from '@triggery/core';
export type Integration = {
enabled: boolean;
webhookUrl: string;
kind: 'slack' | 'discord' | 'custom';
};
type Mention = {
channelId: string;
messageId: string;
fromUserId: string;
fromName: string;
text: string;
};
export const mentionTrigger = createTrigger<{
events: {
'chat:mentioned': Mention;
};
conditions: {
integration: Integration;
};
actions: {
showMentionToast: { from: string; text: string };
postToWebhook: { url: string; kind: Integration['kind']; payload: Mention };
};
}>({
id: 'chat-mention',
events: ['chat:mentioned'],
required: ['integration'],
handler({ event, conditions, actions, check }) {
const mention = event.payload;
// Always notify the user themselves — the integration is *extra*.
actions.showMentionToast?.({ from: mention.fromName, text: mention.text });
// Relay only if the integration is enabled.
if (check.is('integration', i => i.enabled && i.webhookUrl.length > 0)) {
const i = conditions.integration!;
actions.postToWebhook?.({
url: i.webhookUrl,
kind: i.kind,
payload: mention,
});
}
},
});2. The webhook reactor (action does async, fires follow-up event)
Section titled “2. The webhook reactor (action does async, fires follow-up event)”The reactor is where the actual network call happens. It’s an async function — actions may return promises. When the call finishes, the reactor fires chat:webhook-response, which the second trigger picks up.
import { useAction, useEvent } from '@triggery/react';
import { mentionTrigger } from '../../triggers/mention.trigger';
import { webhookResponseTrigger } from '../../triggers/webhook-response.trigger';
export function WebhookReactor() {
const fireResponse = useEvent(webhookResponseTrigger, 'chat:webhook-response');
useAction(mentionTrigger, 'postToWebhook', async ({ url, kind, payload }) => {
try {
const body = formatForKind(kind, payload);
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
fireResponse({ ok: true, url, kind });
} catch (err) {
fireResponse({ ok: false, url, kind, error: (err as Error).message });
}
});
return null;
}
function formatForKind(kind: 'slack' | 'discord' | 'custom', m: { fromName: string; text: string }) {
if (kind === 'slack') return { text: `*${m.fromName}* mentioned you: ${m.text}` };
if (kind === 'discord') return { content: `**${m.fromName}** mentioned you: ${m.text}` };
return m; // raw payload for custom webhooks
}3. The response trigger
Section titled “3. The response trigger”import { createTrigger } from '@triggery/core';
export const webhookResponseTrigger = createTrigger<{
events: {
'chat:webhook-response':
| { ok: true; url: string; kind: 'slack' | 'discord' | 'custom' }
| { ok: false; url: string; kind: 'slack' | 'discord' | 'custom'; error: string };
};
actions: {
showRelaySuccessToast: { kind: 'slack' | 'discord' | 'custom' };
showRelayErrorToast: { kind: 'slack' | 'discord' | 'custom'; error: string };
};
}>({
id: 'chat-webhook-response',
events: ['chat:webhook-response'],
handler({ event, actions }) {
const r = event.payload;
if (r.ok) actions.showRelaySuccessToast?.({ kind: r.kind });
else actions.showRelayErrorToast?.({ kind: r.kind, error: r.error });
},
});The two triggers stay separate on purpose — the mention rule is about “should we relay?”, the response rule is about “what to do with the result?”. Either can grow independently.
4. Producer + providers
Section titled “4. Producer + providers”import { useEvent } from '@triggery/react';
import { mentionTrigger } from '../../triggers/mention.trigger';
export function ChatStream({
currentUserId,
messages,
}: {
currentUserId: string;
messages: readonly { id: string; channelId: string; authorId: string; authorName: string; text: string; mentions: readonly string[] }[];
}) {
const fireMentioned = useEvent(mentionTrigger, 'chat:mentioned');
// Fire on every newly visible mention. In a real app you'd wire this to your
// streaming-message subscription; for the recipe we'll show the button form.
return (
<ul>
{messages.map(m =>
m.mentions.includes(currentUserId) ? (
<li key={m.id}>
<strong>{m.authorName}</strong>: {m.text}
<button
type="button"
onClick={() =>
fireMentioned({
channelId: m.channelId,
messageId: m.id,
fromUserId: m.authorId,
fromName: m.authorName,
text: m.text,
})
}
>
simulate mention arrival
</button>
</li>
) : null,
)}
</ul>
);
}import { useCondition } from '@triggery/react';
import { mentionTrigger, type Integration } from '../../triggers/mention.trigger';
export function IntegrationProvider({
integration,
children,
}: {
integration: Integration;
children: React.ReactNode;
}) {
useCondition(mentionTrigger, 'integration', () => integration, [integration]);
return <>{children}</>;
}5. Notifier
Section titled “5. Notifier”import { useAction } from '@triggery/react';
import { toast } from 'sonner';
import { mentionTrigger } from '../../triggers/mention.trigger';
import { webhookResponseTrigger } from '../../triggers/webhook-response.trigger';
export function MentionNotifier() {
useAction(mentionTrigger, 'showMentionToast', ({ from, text }) => {
toast(`${from} mentioned you`, { description: text });
});
useAction(webhookResponseTrigger, 'showRelaySuccessToast', ({ kind }) => {
toast.success(`Relayed to ${kind}.`);
});
useAction(webhookResponseTrigger, 'showRelayErrorToast', ({ kind, error }) => {
toast.error(`Couldn't relay to ${kind}.`, { description: error });
});
return null;
}6. Wire it up
Section titled “6. Wire it up”import { useState } from 'react';
import { ChatStream } from './features/chat/ChatStream';
import { IntegrationProvider } from './features/integration/IntegrationProvider';
import { MentionNotifier } from './features/notifications/MentionNotifier';
import { WebhookReactor } from './features/integration/WebhookReactor';
const sampleMessages = [
{ id: 'm1', channelId: 'c1', authorId: 'u-alice', authorName: 'Alice',
text: 'hey @bob — can you take a look at the PR?', mentions: ['u-bob'] },
];
export function App() {
const [integration] = useState({
enabled: true,
webhookUrl: 'https://hooks.slack.com/services/T0/B0/X',
kind: 'slack' as const,
});
return (
<IntegrationProvider integration={integration}>
<ChatStream currentUserId="u-bob" messages={sampleMessages} />
<WebhookReactor />
<MentionNotifier />
</IntegrationProvider>
);
}Test the chain
Section titled “Test the chain”import { createTestRuntime, mockAction, mockCondition } from '@triggery/testing';
import { describe, expect, it, vi } from 'vitest';
import { mentionTrigger } from './mention.trigger';
describe('chat-mention', () => {
it('relays to the webhook when integration is enabled', async () => {
const rt = createTestRuntime({ triggers: [mentionTrigger] });
const postToWebhook = vi.fn();
mockCondition(rt, mentionTrigger, 'integration', {
enabled: true,
webhookUrl: 'https://example.com/hook',
kind: 'custom',
});
mockAction(rt, mentionTrigger, 'postToWebhook', postToWebhook);
mockAction(rt, mentionTrigger, 'showMentionToast', () => {});
await rt.fire('chat:mentioned', {
channelId: 'c1', messageId: 'm1', fromUserId: 'u-a',
fromName: 'Alice', text: 'hi',
});
expect(postToWebhook).toHaveBeenCalledWith({
url: 'https://example.com/hook',
kind: 'custom',
payload: expect.any(Object),
});
});
it('skips the webhook when integration is disabled', async () => {
const rt = createTestRuntime({ triggers: [mentionTrigger] });
const postToWebhook = vi.fn();
mockCondition(rt, mentionTrigger, 'integration', {
enabled: false, webhookUrl: '', kind: 'custom',
});
mockAction(rt, mentionTrigger, 'postToWebhook', postToWebhook);
mockAction(rt, mentionTrigger, 'showMentionToast', () => {});
await rt.fire('chat:mentioned', {
channelId: 'c1', messageId: 'm1', fromUserId: 'u-a',
fromName: 'Alice', text: 'hi',
});
expect(postToWebhook).not.toHaveBeenCalled();
});
});