- Use PNPM commands in this repo (workspace uses
pnpm-workspace.yaml). - Always run new/updated tests after creating or changing them.
- Prefer focused verification first (targeted package/spec), then broader checks when needed.
- At the end of each proposal when ready for a PR, run
pnpm ci:checkto ensure all checks pass.
- Monorepo root:
opencom - Main apps:
apps/web,apps/landing,apps/mobile,apps/widget - Backend:
packages/convex - OpenSpec source of truth:
openspec/changes/<change-name>/
- Start every non-trivial task by grounding in current repo state before changing files:
- identify the active scope
- read the relevant files/specs/tests
- verify whether the work is already partly done
- choose a narrow verification plan
- If working from an existing OpenSpec change, always read:
openspec status --change "<change-name>" --jsonopenspec instructions apply --change "<change-name>" --json- the current
proposal.md,design.md,specs/**/*.md, andtasks.md
- Never assume unchecked boxes in
tasks.mdmean the code is still missing. Verify the current implementation first, then update artifacts or tasks to match reality. - Before creating a new OpenSpec change, quickly check for overlapping active changes or existing specs so you do not create duplicates or split ownership accidentally.
- For multi-step work, keep an explicit plan/todo and update it as tasks complete. Prefer one active task at a time.
- When changing course mid-task, record the new scope and the reason in the active change artifacts if they are affected.
- Before marking work complete, verify both code and artifacts:
- code/tests/typechecks reflect the final state
tasks.mdcheckboxes match what is actually done- any follow-up work is written down explicitly instead of left implicit
- If you did not create the current proposal/change, treat the artifacts as hypotheses until verified against the codebase.
- Separate findings into three buckets before editing artifacts:
- already implemented
- still unfinished
- intentionally out of scope or accepted exception
- Only put unfinished work into active proposal/spec/task artifacts.
- If code and artifacts disagree, prefer fixing the artifact first unless the user explicitly asked for implementation.
- When leaving partial progress, record exact remaining file clusters, blockers, and verification still needed so a later pass can continue without re-auditing the whole repo.
- Convex only:
pnpm --filter @opencom/convex typecheck
- Web only:
pnpm --filter @opencom/web typecheck
- Whole workspace:
pnpm typecheck
- Canonical guide:
docs/convex-type-safety-playbook.md - If Convex typecheck hits
TS2589(Type instantiation is excessively deep and possibly infinite) at generated refs likeapi.foo.barorinternal.foo.bar, prefer a local escape hatch instead of broad weakening. - First keep call signatures shallow at the hot spot:
- cast
ctx.scheduler.runAfter,ctx.runQuery, orctx.runMutationto a local shallow function type.
- cast
- If merely referencing
api.../internal...still triggersTS2589, usemakeFunctionReference("module:function")fromconvex/serverat that call site instead of property access on generated refs. - Keep this workaround localized only to pathological sites. Continue using generated
api/internalrefs normally elsewhere. - Expect hidden follow-on errors: rerun
pnpm --filter @opencom/convex typecheckafter each small batch of fixes, because resolving one deep-instantiation site can reveal additional ones.
- Read
docs/convex-type-safety-playbook.mdbefore adding new Convex boundaries. - Frontend runtime/UI modules must not import
convex/reactdirectly. Use local adapters and wrapper hooks instead. - Keep Convex refs at module scope. Never create
makeFunctionReference(...)values inside React components or hooks. - Do not add new
getQueryRef(name: string),getMutationRef(name: string), orgetActionRef(name: string)factories. - Backend cross-function calls should use generated
api/internalrefs by default. Only move to fixedmakeFunctionReference("module:function")refs after a realTS2589hotspot is confirmed. - Keep unavoidable casts localized to adapters or named backend hotspot helpers. Do not spread
as unknown as,unsafeApi, orunsafeInternalthrough runtime code. - After changing a boundary, update the relevant hardening guard:
packages/convex/tests/runtimeTypeHardeningGuard.test.tsapps/web/src/app/typeHardeningGuard.test.tsapps/widget/src/test/refHardeningGuard.test.tspackages/react-native-sdk/tests/hookBoundaryGuard.test.ts
- Before treating an audit item as open work, verify whether it is already implemented and only the guard/proposal text is stale.
- Default classification for current repo state:
packages/sdk-core/src/api/*.tsmanual fixed refs are generally approved TS2589 hotspots, not automatic cleanup targets.packages/sdk-core/src/api/aiAgent.tsalready routesgetRelevantKnowledgethroughclient.action(...); do not reopen the old query-path migration unless you find a current regression.packages/convex/convex/embeddings.tsbatching/backfill concurrency work is already in place; do not create new perf tasks forgenerateBatch,backfillExisting, orgenerateBatchInternalunless the current code regressed.packages/convex/convex/testAdmin.tsis an explicit dynamic exception because it intentionally dispatches caller-selected internal test mutations.
- Treat these patterns differently:
- Remaining cleanup target: generic
name: stringref helpers such asmakeInternalQueryRef(name)/getQueryRef(name)in covered runtime files. - Usually acceptable hotspot: fixed module-scope
makeFunctionReference("module:function")constants with a narrow comment or guard-railedTS2589justification. - Accepted exception: intentionally dynamic dispatch that is security-constrained and documented (currently
testAdmin.ts).
- Remaining cleanup target: generic
- When cleaning backend Convex boundaries, prefer this order:
- Generated
api/internalrefs - Named shallow runner helper at the hot spot
- Fixed
makeFunctionReference("module:function")constant - Only if intentionally dynamic and documented, a narrow exception
- Generated
- Do not add new generic helper factories to shared ref modules. If a module exists to share refs, export fixed named refs from it.
- Create isolated test data using helpers
- Clean up after tests
- Use descriptive test names
- Test both success and error cases
- Use
data-testidattributes for E2E selectors - Keep tests focused and independent
- Share state between tests
- Rely on specific database IDs
- Skip cleanup in afterAll
- Hard-code timeouts (use Playwright's auto-wait)
Use these tags to highlight important information in code comments:
IMPORTANT:- Critical information that must not be overlookedNOTE:- Helpful context or clarificationWARNING:- Potential pitfalls or dangerous operationsTODO:- Future work that should be doneFIXME:- Known issues that need fixing
- Use
MUST/MUST NOTfor hard requirements - Use
NEVER/ALWAYSfor absolute rules - Use
AVOIDfor anti-patterns to stay away from - Use
DO NOTfor explicit prohibitions
// IMPORTANT: This function must be called before any Convex operations
// NOTE: The widget uses Shadow DOM, so overlays must portal into the shadow root
// WARNING: Never fall back to wildcard "*" for CORS
// TODO: Add rate limiting to this endpoint
// FIXME: This cast should be removed after TS2589 is resolved- Separate orchestration from rendering
- Extract helper logic from page components
- Use explicit domain modules instead of co-locating all logic
- Preserve existing behavior when refactoring
- Single Responsibility: Each module should have one clear purpose
- Explicit Contracts: Modules must expose typed internal contracts
- Preserve Semantics: Refactoring must preserve existing behavior
- Shared Utilities: Common logic should be extracted to shared modules
- Controller/View Separation: Separate orchestration from rendering
- Domain Modules: Group related functionality by domain
- Adapter Pattern: Use adapters for external dependencies
- Wrapper Hooks: Wrap external hooks with local adapters
Use the standardized error functions from packages/convex/convex/utils/errors.ts:
throwNotFound(resourceType)- Resource not foundthrowNotAuthenticated()- Authentication requiredthrowPermissionDenied(permission?)- Permission denied
- Use standardized non-blocking error feedback for frontend paths
- Provide actionable user messaging
- Centralize unknown error mapping for covered paths
- OpenSpec specs are the source of truth for requirements
docs/contains reference documentationAGENTS.mdcontains AI agent guardrails- Code comments provide inline guidance
- When adding new features or changing behavior
- When fixing bugs that affect user-facing behavior
- When refactoring that changes module boundaries
- When adding new patterns or conventions
- When converting a repo audit into OpenSpec artifacts, put only unfinished work into
proposal.md, spec deltas, andtasks.md. - Explicitly call out already-finished adjacent work so a follow-up agent does not reopen it by mistake.
- For the current Convex hardening area, the default out-of-scope items are:
- sdk-core
getRelevantKnowledgeaction routing - embedding batching/backfill concurrency in
packages/convex/convex/embeddings.ts
- sdk-core
- If you change the covered hardening inventory or accepted exceptions, update the matching guard in the same change. Common files:
packages/convex/tests/runtimeTypeHardeningGuard.test.tspackages/sdk-core/tests/refHardeningGuard.test.ts
- When leaving work half-finished, record the remaining file clusters explicitly in
openspec/changes/<change>/tasks.mdso the next agent can resume without re-auditing the repo.
- Convex targeted file:
pnpm --filter @opencom/convex test -- --run tests/<file>.test.ts
- Convex full package tests:
pnpm --filter @opencom/convex test
- Web unit tests:
pnpm --filter @opencom/web test
- Web E2E (single file):
pnpm playwright test apps/web/e2e/<spec>.ts --project=chromium
- Build/distribute widget before web E2E runs:
bash scripts/build-widget-for-tests.sh
- If Convex-backed tests need env values loaded in shell:
bash -lc 'set -a; source packages/convex/.env.local; set +a; <your command>'
openspec status --change "<change-name>" --json
openspec instructions apply --change "<change-name>" --json
openspec validate <change-name> --strict --no-interactive
tasks.mdcan stay blocked until bothdesign.mdandspecs/**/*.mdare ready.- If status shows:
tasks: blockedmissingDeps: ["design", "specs"]this is expected; finish design/spec artifacts first.
- Implement scoped code changes.
- Run package-level typecheck(s).
- Run targeted tests for touched area.
- Run strict OpenSpec validation.
- Update
openspec/changes/<change>/tasks.mdcheckboxes. - Sync tracker in
openspec/proposal-execution-plan.mdwhen proposal status changes.
/opsx-apply— implement tasks for a change/opsx-continue— advance artifact workflow/opsx-verify— verify implementation vs artifacts/opsx-archive— archive completed change
Use these when working within OpenSpec-driven requests to reduce setup time in fresh chats.
Warning: Running scripts inline causes the terminal to hang and crash. Create files and run them that way. Avoid running commmands like ... node - <<"NODE ..." or python3 - <<'PY' ...
This is the canonical guide for adding or changing Convex-backed code in this repo.
Use it when you are:
- adding a new Convex
query,mutation, oraction - calling one Convex function from another
- wiring a new frontend feature to Convex
- hitting
TS2589(Type instantiation is excessively deep and possibly infinite) - deciding where a cast or
makeFunctionReference(...)is acceptable
Historical hardening notes still exist in openspec/archive/refactor-* and runtime-type-hardening-2026-03-05.md, but this file is the current source of truth for new code.
- Keep runtime and UI code on explicit, local types.
- Keep unavoidable Convex typing escape hatches small and centralized.
- Prevent new generic string-ref factories, broad casts, and component-local ref creation.
- Make it obvious which pattern to use for each call site.
| Situation | Preferred approach | Where | Why |
|---|---|---|---|
| Define a new public Convex query or mutation | Export a normal Convex function with narrow v.* args and a narrow return shape |
packages/convex/convex/** |
Keeps the source contract explicit and reusable |
| Call Convex from web or widget UI/runtime code | Use the local surface adapter plus a feature-local wrapper hook or fixed ref constant | apps/web/src/**, apps/widget/src/** |
Keeps convex/react and ref typing out of runtime/UI modules |
| Call one Convex function from another and generated refs typecheck normally | Use generated api.* / internal.* refs |
packages/convex/convex/** |
This is the default, simplest path |
Call one Convex function from another and generated refs hit TS2589 |
Add a local shallow runQuery / runMutation / runAction / runAfter helper |
the hotspot file only | Shrinks type instantiation at the call boundary |
The generated ref itself still triggers TS2589 |
Replace only that hot ref with a fixed, typed makeFunctionReference("module:function") constant |
the hotspot file only | Avoids broad weakening of the entire module |
| Convex React hook tuple typing still needs help | Keep a tiny adapter-local helper/cast in the surface adapter | adapter file only | Localizes the last unavoidable boundary |
Use the surface adapter layer instead:
- web:
apps/web/src/lib/convex/hooks.ts - widget:
apps/widget/src/lib/convex/hooks.ts - mobile: follow the same local-wrapper pattern; do not add new direct screen/context-level
convex/reactusage
Direct convex/react imports are only acceptable in:
- explicit adapter files
- bootstrap/provider wiring
- targeted tests that intentionally mock the adapter boundary
The current hardening guards freeze these boundaries:
apps/web/src/app/typeHardeningGuard.test.tsapps/widget/src/test/refHardeningGuard.test.tsapps/mobile/src/typeHardeningGuard.test.tspackages/react-native-sdk/tests/hookBoundaryGuard.test.ts
Bad:
function WidgetPane() {
const listRef = makeFunctionReference<"query", Args, Result>("messages:list");
const data = useQuery(listRef, args);
}Good:
const LIST_REF = widgetQueryRef<Args, Result>("messages:list");
function WidgetPane() {
const data = useWidgetQuery(LIST_REF, args);
}All refs must be module-scope constants.
Do not introduce helpers like:
getQueryRef(name: string)getMutationRef(name: string)getActionRef(name: string)
Those patterns weaken the type boundary and make review harder. Some older code still has them, but they are legacy, not the standard for new work.
Use named fixed refs instead:
const LIST_MESSAGES_REF = webQueryRef<ListMessagesArgs, MessageRecord[]>("messages:list");
const SEND_MESSAGE_REF = webMutationRef<SendMessageArgs, Id<"messages">>("messages:send");Allowed:
- a tiny adapter-local cast needed to satisfy a Convex hook tuple type
- a hotspot-local shallow helper for
ctx.runQuery,ctx.runMutation,ctx.runAction, orctx.scheduler.runAfter - a hotspot-local typed
makeFunctionReference("module:function")when generated refs triggerTS2589
Not allowed for new code:
as any- broad
unsafeApi/unsafeInternalobject aliases in runtime code - repeated
as unknown asacross multiple call sites - hiding transport typing inside UI/controller modules
If you intentionally add a new approved boundary, document it in the relevant guard test at the same time.
Default backend pattern:
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
import type { Id } from "./_generated/dataModel";
type VisitorSummary = {
_id: Id<"visitors">;
name?: string;
email?: string;
};
export const listByWorkspace = query({
args: {
workspaceId: v.id("workspaces"),
},
handler: async (ctx, args): Promise<VisitorSummary[]> => {
const visitors = await ctx.db
.query("visitors")
.withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
.collect();
return visitors.map((visitor) => ({
_id: visitor._id,
name: visitor.name,
email: visitor.email,
}));
},
});Rules:
- Use narrow
v.*validators. - Prefer explicit local return types for shared, frontend-facing, or cross-function contracts.
- Convert untyped or broad data to a narrow shape before returning it.
- If you need
v.any(), document it insecurity/convex-v-any-arg-exceptions.json.
Runtime/UI files should consume feature-local wrappers or fixed refs through the local adapter.
Widget example:
import type { Id } from "@opencom/convex/dataModel";
import { useWidgetQuery, widgetQueryRef } from "../lib/convex/hooks";
type TicketRecord = {
_id: Id<"tickets">;
subject: string;
};
const VISITOR_TICKETS_REF = widgetQueryRef<
{ workspaceId: Id<"workspaces">; visitorId: Id<"visitors">; sessionToken: string },
TicketRecord[]
>("tickets:listByVisitor");
export function useVisitorTickets(
workspaceId: Id<"workspaces"> | undefined,
visitorId: Id<"visitors"> | null,
sessionToken: string | null
) {
return useWidgetQuery(
VISITOR_TICKETS_REF,
workspaceId && visitorId && sessionToken ? { workspaceId, visitorId, sessionToken } : "skip"
);
}Use the same structure in web with webQueryRef, webMutationRef, webActionRef, useWebQuery, useWebMutation, and useWebAction.
Rules:
- Define refs once at module scope.
- Keep
skip/ gating logic in the wrapper where practical. - Export narrow feature-local result types instead of leaking giant inferred shapes.
- Do not import
convex/reactdirectly in feature components, screens, contexts, or controller hooks.
Start here when the types are normal:
import { internal } from "./_generated/api";
await ctx.runMutation(internal.notifications.deliver, {
conversationId,
});This is the standard path until it hits a real TS2589 problem.
If ctx.runQuery(...), ctx.runMutation(...), ctx.runAction(...), or ctx.scheduler.runAfter(...) causes deep-instantiation errors, add a local helper:
import { type FunctionReference } from "convex/server";
type ConvexRef<
Type extends "query" | "mutation" | "action",
Visibility extends "internal" | "public",
Args extends Record<string, unknown>,
Return = unknown,
> = FunctionReference<Type, Visibility, Args, Return>;
function getShallowRunMutation(ctx: { runMutation: unknown }) {
return ctx.runMutation as unknown as <
Visibility extends "internal" | "public",
Args extends Record<string, unknown>,
Return,
>(
mutationRef: ConvexRef<"mutation", Visibility, Args, Return>,
mutationArgs: Args
) => Promise<Return>;
}Then call through the helper:
const runMutation = getShallowRunMutation(ctx);
await runMutation(internal.notifications.deliver, { conversationId });If simply referencing api.foo.bar or internal.foo.bar still triggers TS2589, switch only that hot call site to a fixed typed ref:
import { makeFunctionReference, type FunctionReference } from "convex/server";
type DeliverArgs = { conversationId: Id<"conversations"> };
type DeliverResult = null;
type ConvexRef<
Type extends "query" | "mutation" | "action",
Visibility extends "internal" | "public",
Args extends Record<string, unknown>,
Return = unknown,
> = FunctionReference<Type, Visibility, Args, Return>;
const DELIVER_NOTIFICATION_REF = makeFunctionReference<"mutation", DeliverArgs, DeliverResult>(
"notifications:deliver"
) as unknown as ConvexRef<"mutation", "internal", DeliverArgs, DeliverResult>;Use this only after the generated ref path proved pathological.
- the call is backend-to-backend
- the generated ref typechecks normally
- you are not in a known
TS2589hotspot
- you are in a surface adapter or feature-local wrapper file
- you need a stable local ref for a frontend wrapper
- a backend hotspot still blows up after trying generated refs
- the consumer is React UI, runtime, controller, screen, or context code
- the feature needs gating or
skipbehavior - you want to normalize the result shape once for multiple consumers
- the problem is
ctx.runQuery/ctx.runMutation/ctx.runAction/runAfter - the ref type is okay, but the invocation is too deep
- Convex’s React hook typing still needs an exact tuple or helper shape
- the cast can stay in the adapter file and nowhere else
- Default to generated refs.
- Localize
TS2589workarounds with namedgetShallowRun*helpers. - If needed, use fixed typed refs at the hotspot only.
- Keep guard coverage in
packages/convex/tests/runtimeTypeHardeningGuard.test.ts.
- Feature/runtime code should not import
convex/reactdirectly. - Use feature-local wrapper hooks and the web adapter in
apps/web/src/lib/convex/hooks.ts. - Keep refs at module scope.
- Guard coverage lives in
apps/web/src/app/typeHardeningGuard.test.ts.
- Feature/runtime code should not import
convex/reactdirectly. - Use the widget adapter in
apps/widget/src/lib/convex/hooks.ts. - The only remaining adapter escape hatch is the query-args tuple helper required by Convex’s hook typing.
- Guard coverage lives in
apps/widget/src/test/refHardeningGuard.test.ts.
- Target the same pattern as web/widget: local wrapper hooks plus module-scope typed refs.
- Do not add new direct
convex/reactusage to screens, contexts, or controller-style hooks. - If a local adapter/wrapper does not exist for the feature yet, create one instead of importing hooks directly into runtime UI.
- Guard coverage lives in
apps/mobile/src/typeHardeningGuard.test.ts.
function getQueryRef(name: string) { ... }function getMutationRef(name: string) { ... }function getActionRef(name: string) { ... }- component-local
makeFunctionReference(...) as any- broad
unsafeApi/unsafeInternalaliases - scattering the same
as unknown asacross many call sites - returning
unknownorstringwhen a brandedId<"...">or explicit object type is the real contract
After changing Convex typing boundaries:
- Run the touched package typecheck first.
- Run the touched package tests.
- Run the relevant hardening guard tests.
- If this work is OpenSpec-driven, run strict OpenSpec validation.
Useful commands:
pnpm --filter @opencom/convex typecheck
pnpm --filter @opencom/web typecheck
pnpm --filter @opencom/widget typecheck
pnpm --filter @opencom/convex test -- --run tests/runtimeTypeHardeningGuard.test.ts
pnpm --filter @opencom/web test -- --run src/app/typeHardeningGuard.test.ts
pnpm --filter @opencom/widget test -- --run src/test/refHardeningGuard.test.ts
pnpm exec vitest run --config apps/mobile/vitest.config.ts apps/mobile/src/typeHardeningGuard.test.tsIf you are about to:
- add a new direct
convex/reactimport in feature code - add a new
get*Ref(name: string)factory - add
unsafeApi,unsafeInternal,as any, or repeatedas unknown as - create a ref inside a React component
stop and use one of the standard patterns above instead.
This project uses Convex as its backend.
When working on Convex code, always read convex/_generated/ai/guidelines.md first for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data.
Convex agent skills for common tasks can be installed by running npx convex ai-files install.