Event Patterns
How events flow from MCP tools through Inngest to background functions -- emitter design, event naming, and decoupling strategy.
Inngest functions are triggered by named events. The event emitters live in @repo/mcp; the consumers live in apps/web/src/inngest/functions/. This separation means MCP tools do not know or care what processes their events.
Event Flow
MCP tool @repo/mcp Inngest Server apps/web
─────────── ───────── ────────────── ────────
store_research() --> emitContentCreated() --> event queue --> index-content function
store_research_output() (4 durable steps)
- An MCP tool stores a record in the database
- The tool calls
emitContentCreated()frompackages/mcp/src/events.ts emitContentCreatedsends asearch/content.createdevent to Inngest via HTTP- Inngest persists the event and invokes the matching function(s) via callback to
/api/inngest - The
index-contentfunction processes the content through its four durable steps
Current Events
| Event Name | Emitter | Consumer | Purpose |
|---|---|---|---|
search/content.created | emitContentCreated() in @repo/mcp | index-content in apps/web | Trigger hybrid search indexing when research content is stored |
auth/user.created | (not yet wired) | welcome-email in apps/web | Trigger welcome email on user signup |
Event Naming Convention
Events use the format domain/entity.action:
- domain -- the system area (e.g.,
search,auth) - entity -- the thing being acted on (e.g.,
content,user) - action -- what happened (e.g.,
created,updated,deleted)
Examples: search/content.created, auth/user.created, research/plan.completed.
The Emitter: emitContentCreated
The emitter in packages/mcp/src/events.ts follows a graceful-degradation pattern:
let inngest: Inngest | null = null;
function getInngest(): Inngest | null {
// Skip event emission if Inngest is not configured
if (!process.env["INNGEST_BASE_URL"] && !process.env["INNGEST_DEV"]) {
return null;
}
if (!inngest) {
inngest = new Inngest({ id: "trovella" });
}
return inngest;
}
export async function emitContentCreated(data: {
sourceTable: "research_artifact" | "research_output" | "extraction_result";
sourceId: string;
title: string;
content: string;
organizationId: string;
userId: string;
artifactType?: string;
mediaType?: string;
planId?: string;
}): Promise<void> {
const client = getInngest();
if (!client) return; // no-op when Inngest is not available
await client.send({
name: "search/content.created",
data,
});
}
Key design decisions:
- Lazy initialization -- the Inngest client is created on first use, not at module load
- No-op without config -- if
INNGEST_BASE_URLandINNGEST_DEVare both unset, the emitter returns silently. This means database seeding and testing work without Inngest running. - Same client ID -- both the emitter (
@repo/mcp) and the consumer (apps/web) useid: "trovella"so Inngest treats them as the same application
Calling the Emitter from MCP Tools
The MCP tools store_research and store_research_output call emitContentCreated after writing the database record:
// Store the artifact in the database first
await withTenantContext(organizationId, auth.userId, async (tx) => {
await tx.insert(researchArtifact).values({
/* ... */
});
});
// Then trigger background indexing (fire-and-forget)
if (contentText) {
await emitContentCreated({
sourceTable: "research_artifact",
sourceId: id,
title,
content: contentText,
organizationId,
userId: auth.userId,
artifactType,
planId: planId ?? undefined,
});
}
The same pattern is used in store_research_output with sourceTable: "research_output".
Adding a New Event
- Define the event emitter function in
packages/mcp/src/events.ts(or the appropriate package) - Use the
domain/entity.actionnaming convention - Include the graceful no-op pattern (
if (!client) return) - Create the consumer function in
apps/web/src/inngest/functions/(see Job Definitions) - Always include
organizationIdanduserIdin the event data for tenant-scoped processing
Design Principle: Decoupling
The event-driven pattern means:
- Emitters are unaware of consumers --
store_researchdoes not import or referenceindex-content - Multiple consumers per event -- adding a second consumer (e.g., notification, analytics) requires no changes to the emitter
- Testing in isolation -- MCP tools can be tested without Inngest running (the emitter no-ops)
- Failure isolation -- if indexing fails, the stored record is unaffected