Skip to Content
User GuidesFor Developers

Guide for Developers

Technical guide for engineers building on the Loop Health platform.

Development Workflow

Day-to-Day Commands

# Start development (all apps) pnpm dev # Start a specific app pnpm --filter @loop/admin dev pnpm --filter @loop/my-loop-health dev pnpm --filter @loop/luna dev pnpm --filter @loop/patient-graph-api dev pnpm --filter @loop/docs dev # Type-check everything pnpm typecheck # Lint pnpm lint # Run tests pnpm test # Build all pnpm build

Adding a Dependency

# To a specific package pnpm --filter @loop/core add zod # Dev dependency pnpm --filter @loop/core add -D vitest # To the workspace root pnpm add -w turborepo

Creating a New Package

  1. Create directory: packages/my-package/
  2. Add package.json with "name": "@loop/my-package"
  3. Add tsconfig.json extending @loop/tsconfig/node.json
  4. Export from src/index.ts
  5. Add to consuming packages’ package.json as "@loop/my-package": "workspace:*"

Code Patterns

Result Type (Error Handling)

Use Result<T> from @loop/core instead of try/catch:

import { ok, err, tryCatch, type Result } from '@loop/core'; // Return ok or err function divide(a: number, b: number): Result<number> { if (b === 0) return err(createError('VALIDATION', 'Cannot divide by zero')); return ok(a / b); } // Wrap throwing code const result = await tryCatch(() => fetch('/api/data')); if (result.ok) { console.log(result.value); } else { console.error(result.error); } // Chain with map/flatMap const doubled = map(divide(10, 2), (n) => n * 2);

Logging

Use createLogger() from @loop/core — never console.*:

import { createLogger } from '@loop/core'; const logger = createLogger('my-service'); logger.info('Processing request', { userId: '123' }); logger.error('Failed to process', { error });

In development, logs are pretty-printed. In production, they’re JSON for ingestion.

Zod Validation

Schemas are centralized in @loop/shared:

import { z } from 'zod'; // Define schema export const createProfileSchema = z.object({ email: z.string().email(), firstName: z.string().min(1), lastName: z.string().min(1), biologicalSex: z.enum(['male', 'female', 'other']), }); // Use in routes app.post('/profiles', zValidator('json', createProfileSchema), async (c) => { const data = c.req.valid('json'); // data is fully typed });

Repository Pattern

Database access uses the repository pattern:

import { createRepositories } from '@loop/patient-graph'; const repos = createRepositories(connection); // Type-safe CRUD const profiles = await repos.profiles.list({ limit: 20 }); const profile = await repos.profiles.findById(id); const created = await repos.profiles.create(data); await repos.profiles.update(id, patch); await repos.profiles.delete(id);

Hono Middleware

Use @loop/hono for consistent middleware:

import { createApp } from '@loop/hono'; const app = createApp(); // Auth middleware app.use('/*', requireAuth); // Role-based access app.use('/admin/*', requireRole('admin')); // Rate limiting app.use('/*', rateLimit({ max: 100, window: 60 })); // Response helpers app.get('/users', async (c) => { const users = await getUsers(); return success(c, users); });

Working with Packages

Import Convention

Always use the @loop/* namespace:

import { ok, err, createLogger } from '@loop/core'; import { checkAccess } from '@loop/shared/rbac'; import { createRepositories } from '@loop/patient-graph'; import { parseLabReport } from '@loop/biomarker-parser';

ESM Extensions

Use .js extensions for local imports (ESM compatibility):

// Correct import { helper } from './utils.js'; // Incorrect (will fail in ESM) import { helper } from './utils';

Package Boundaries

Each package has a clear responsibility. Don’t bypass boundaries:

  • @loop/core — Zero internal deps, foundational patterns
  • @loop/shared — Schemas and types consumed by many packages
  • @loop/health-data — Static data only, no database access
  • @loop/patient-graph — Database operations only

Testing

Running Tests

# All tests pnpm test # Specific package pnpm --filter @loop/health-engine test # Watch mode pnpm --filter @loop/health-engine test -- --watch # With coverage pnpm --filter @loop/health-engine test -- --coverage

Writing Tests

Tests use Vitest:

import { describe, it, expect } from 'vitest'; import { assessQualification } from '@loop/health-engine'; describe('assessQualification', () => { it('qualifies a healthy patient', () => { const result = assessQualification( { age: 35, sex: 'male', conditions: [], medications: [] }, [{ code: 'testosterone-total', value: 650, unit: 'ng/dL' }], 'bpc-157' ); expect(result.ok).toBe(true); expect(result.value.status).toBe('qualified'); }); it('disqualifies for absolute contraindication', () => { const result = assessQualification( { age: 30, sex: 'female', conditions: ['pregnancy'], medications: [] }, [], 'bpc-157' ); expect(result.ok).toBe(true); expect(result.value.status).toBe('disqualified'); }); });

Debugging

Common Issues

“Module not found” errors: Run pnpm build from the root to build all packages.

Type errors across packages: Run pnpm typecheck to see all type errors. Fix them in dependency order (core → shared → other packages → apps).

Database schema changes: After modifying Drizzle schemas, run migrations or schema push:

pnpm --filter @loop/patient-graph-api db:push

Clerk auth not working locally: Verify CLERK_SECRET_KEY and NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY are set. Check that your Clerk instance has the correct redirect URLs.

Logging

Enable debug logging:

LOG_LEVEL=debug pnpm --filter @loop/patient-graph-api dev

API Debugging

Use the built-in requestId in error responses to trace requests through logs:

{ "error": { "code": "NOT_FOUND", "message": "Profile not found" }, "meta": { "requestId": "req_abc123" } }

Search logs for req_abc123 to find the full request trace.