Function Boundaries
Preserve Crux run/span context across Convex actions, queries, mutations, and scheduled work.
Function Boundaries
Use @crux/convex/server for Convex functions that participate in a Crux execution graph.
import { action, internalAction, query, mutation } from '@crux/convex/server'The wrappers preserve Convex's normal function shape while adding:
- a hidden optional
__cruxargument for context propagation ctx.cruxhelpers inside handlers- automatic context restoration on the receiving side
- bounded flush for actions and internal actions
Public Action Entrypoint
'use node'
import { action } from '@crux/convex/server'
import { generate } from '@crux/ai'
import { openai } from '@ai-sdk/openai'
import { v } from 'convex/values'
import { chatPrompt } from '../prompts/support'
export const respond = action({
observabilityName: 'support chat',
observabilityRootPrimitive: 'agent.run',
observabilityAttributes: { agentId: 'support-chat' },
args: {
threadId: v.string(),
message: v.string(),
},
handler: async (ctx, args) => {
await requireThreadAccess(ctx, args.threadId)
const result = await generate(chatPrompt, {
model: openai('gpt-4o'),
input: { message: args.message, threadId: args.threadId },
})
return { text: result.text }
},
})When a Crux-aware public action starts without incoming context, it can become the run root. Set observabilityName, observabilityRootPrimitive, and observabilityAttributes on entrypoints so the visible run is the semantic operation (support chat, daily briefing, agent.run) instead of a random infrastructure call.
Child Actions
Use ctx.crux.runAction() for related AI work:
import { internal } from '../_generated/api'
const plan = await ctx.crux.runAction('plan research', internal.research.plan, {
threadId: args.threadId,
message: args.message,
})
const answer = await ctx.crux.runAction('write answer', internal.writer.answer, {
threadId: args.threadId,
plan,
})This records an inspectable runtime.convex.action boundary, flushes the boundary start before the child worker runs, and propagates __crux to the child. The devtools backend can fold infrastructure-only boundaries while keeping every canonical record searchable.
Queries And Mutations
Use query() and mutation() when a read/write needs Crux context propagation, but should not create a standalone run.
import { query } from '@crux/convex/server'
import { v } from 'convex/values'
export const getResearchInputs = query({
args: { threadId: v.string() },
handler: async (ctx, args) => {
return ctx.db
.query('researchInputs')
.withIndex('by_thread', (q) => q.eq('threadId', args.threadId))
.collect()
},
})Inside an active Crux action, call it with ctx.crux.runQuery():
const inputs = await ctx.crux.runQuery('load research inputs', internal.research.data.getResearchInputs, {
threadId,
})Best practice: keep routine app reads/writes as normal Convex functions unless they are part of a Crux execution and need trace continuity.
Scheduling
Use ctx.crux.scheduler.runAfter() for detached scheduled work. By default it records the enqueue operation in the current trace, but the scheduled action starts its own run when it executes later:
await ctx.crux.scheduler?.runAfter('stream chat response', 0, internal.chat.streamResponse, {
threadId,
})Only propagate observability explicitly when the scheduled action is a true continuation of the same durable run, such as a flow resume that stored the original observability context:
await ctx.crux.scheduler?.runAfter(
'resume writer flow',
0,
internal.writer.resume,
{ resume: flowId },
{ observability: snapshot.observabilityContext },
)Scheduling crosses both time and worker boundaries, so detached work should not inherit a parent span that may already be closed.
Escape Hatches
If you must call across a boundary that is not wrapped by @crux/convex/server, manually capture and restore context with observe.captureContext() and observe.withContext(). Prefer the Convex wrappers whenever possible.
Best Practices
- Label child actions with user-meaningful names like
plan research, not generated IDs. - Use raw
ctx.runAction()only for unrelated application infrastructure. - Keep public auth, tenant checks, and rate limits at public action boundaries.
- Do not add
__cruxto user schemas manually; the wrappers do that. - Do not use
flow()as a generic tracing wrapper for Convex Agent chat turns. Agent turns should be agent runs; flows are for actual Crux flows.