Cloud & Tunnel Setup
Connect cloud backends like Convex, Vercel, or Lambda to your local devtools server.
When your backend runs locally, Quality CLI auto-attach works against loopback crux dev origins
without project config. When application runtime code runs in the cloud (Convex, Vercel, AWS
Lambda), the devtools server needs a public URL so that runtime can reach it.
Creating a tunnel
The --tunnel flag creates a public URL automatically:
crux dev --tunnelThe tunnel URL is displayed in both the CLI output and the TUI header:
OK Server ready at http://localhost:4400
OK Tunnel: https://your-subdomain.ngrok-free.devIn TUI mode (crux dev --tunnel --tui), the tunnel URL appears in the dashboard header bar alongside the local URL.
Connecting your backend
Set the DEVTOOLS_URL environment variable to the tunnel URL. For Convex:
npx convex env set DEVTOOLS_URL https://your-subdomain.ngrok-free.devAdd devtools.serverUrl only for this explicit tunnel/local-runtime connection:
config({
devtools: { serverUrl: process.env.DEVTOOLS_URL },
})For other platforms, set DEVTOOLS_URL however your platform handles environment variables (e.g. .env files, Vercel dashboard, AWS Parameter Store).
Free ngrok plans generate a new URL on every restart. If traces stop appearing, check that DEVTOOLS_URL matches the
current tunnel URL shown in the CLI output.
Tunnel providers
The CLI tries providers in order:
- ngrok — requires
@ngrok/ngrokinstalled andNGROK_AUTHTOKENset. Most reliable for WebSocket connections. With a paid plan, you get a stable subdomain. - localtunnel — requires
localtunnelinstalled. Zero-config, no auth needed.
Install one as a dev dependency:
pnpm add -D @ngrok/ngrok # recommended
# or
pnpm add -D localtunnel # zero-config alternativeFor ngrok, set the auth token:
export NGROK_AUTHTOKEN=your_token_hereSessions & Flows
When traces arrive from a cloud backend, they can be grouped into logical sessions and structured pipelines for easier navigation in the Timeline.
Session scope
withSession groups all generate() calls inside it under a single session ID, visible as a collapsible group in the Timeline.
import { withSession, createSessionId } from '@crux/core'
const sessionId = createSessionId()
await withSession(sessionId, async () => {
await generate(titlePrompt, { model, input })
await generate(replyPrompt, { model, input })
})Flow scope
flow adds structured pipeline grouping within a session. Named steps via flow.step() tag every nested generate() call with a stepId and stepLabel.
import { withSession, flow, createSessionId } from '@crux/core'
const researchFlow = flow('research', async (flow) => {
const plan = await flow.step('plan', () => generate(planner, { model, input }))
return flow.step('search', () => generate(searcher, { model, input: plan }))
})
await withSession(createSessionId(), () => researchFlow.run())In the Timeline, the session appears as a collapsible group with each flow shown as a sub-group, and steps within each flow labeled and nested.
Cross-action flows (Convex)
For Convex, use @crux/convex/server action boundaries and ctx.crux.runAction() so the hidden __crux envelope carries run/span context across workers. See the Convex observability guide for cross-action flows and devtools setup.
Hierarchy in Timeline
Session "chat-abc"
├── Flow "content-pipeline"
│ ├── step:plan → trace-001
│ ├── step:research
│ │ └── Flow "research" ← nested flow
│ │ ├── step:find-sources → trace-002
│ │ └── step:synthesize → trace-003
│ └── step:write → trace-004
├── Flow "research:economy"
│ └── ...
└── trace-005 (standalone, no flow)Nested flows appear as children of their parent flow. The parentFlowId relationship is tracked automatically when you run a flow inside another flow.
Quality↔runtime comparison
Quality evaluation cells and runtime flows share the same step labels in the trace model, so the Timeline can compare a production run against evaluation runs of the same flow.