Trovella Wiki

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)
  1. An MCP tool stores a record in the database
  2. The tool calls emitContentCreated() from packages/mcp/src/events.ts
  3. emitContentCreated sends a search/content.created event to Inngest via HTTP
  4. Inngest persists the event and invokes the matching function(s) via callback to /api/inngest
  5. The index-content function processes the content through its four durable steps

Current Events

Event NameEmitterConsumerPurpose
search/content.createdemitContentCreated() in @repo/mcpindex-content in apps/webTrigger hybrid search indexing when research content is stored
auth/user.created(not yet wired)welcome-email in apps/webTrigger 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_URL and INNGEST_DEV are 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) use id: "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

  1. Define the event emitter function in packages/mcp/src/events.ts (or the appropriate package)
  2. Use the domain/entity.action naming convention
  3. Include the graceful no-op pattern (if (!client) return)
  4. Create the consumer function in apps/web/src/inngest/functions/ (see Job Definitions)
  5. Always include organizationId and userId in the event data for tenant-scoped processing

Design Principle: Decoupling

The event-driven pattern means:

  • Emitters are unaware of consumers -- store_research does not import or reference index-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

On this page