Skip to content

Latest commit

 

History

History
472 lines (361 loc) · 16.4 KB

File metadata and controls

472 lines (361 loc) · 16.4 KB

Agent Instructions

This file provides guidance to coding agents like Claude Code (claude.ai/code) and OpenCode when working with code in this repository.

Git Workflow

Always commit and push changes after completing a task. Follow these rules:

  1. After making code changes, always commit with a descriptive message
  2. Push commits to the current feature branch
  3. NEVER push directly to main or test branches - always use feature branches and PRs
  4. Before pushing, verify the current branch is not main or test
  5. Open PRs against the test branch, not main
  6. After pushing, check if a PR exists for the branch. If not, create one with gh pr create --base test
  7. After creating a PR, always wait for explicit user approval before merging. Never merge PRs autonomously.

Starting a New Task

When starting a new task, first sync the test branch with main:

git checkout test && git pull origin test && git fetch origin main && git merge origin/main && git push origin test

Then checkout main, pull latest, and create your feature branch from there.

This is the only time you should push directly to test.

Build Commands

pnpm install        # Install dependencies
pnpm dev            # Start dev server
pnpm build          # Production build
pnpm test           # Run vitest
pnpm test:watch     # Watch mode
pnpm lint           # Fix lint issues
pnpm lint:check     # Check for lint issues
pnpm format         # Run prettier
pnpm format:check   # Check formatting

Architecture

  • Next.js 16 API service with App Router
  • x402-next middleware for crypto payments on Base network
  • app/api/ - API routes (image generation, artists, accounts, etc.)
  • lib/ - Business logic organized by domain:
    • lib/ai/ - AI/LLM integrations
    • lib/emails/ - Email handling (Resend)
    • lib/supabase/ - Database operations
    • lib/trigger/ - Trigger.dev task triggers
    • lib/x402/ - Payment middleware utilities

Supabase Database Operations

CRITICAL: NEVER import @/lib/supabase/serverClient outside of lib/supabase/ directory.

All Supabase database calls must be in lib/supabase/[table_name]/[function].ts.

If you need database access in lib/auth/, lib/chats/, or any other domain folder:

  1. First check if a function already exists in lib/supabase/[table_name]/
  2. If not, create a new function in lib/supabase/[table_name]/ first
  3. Then import and use that function in your domain code

WRONG - Direct Supabase call in domain code:

// lib/auth/someFunction.ts
import supabase from "@/lib/supabase/serverClient";  // NEVER DO THIS
const { data } = await supabase.from("accounts").select("*");

CORRECT - Import from supabase lib:

// lib/auth/someFunction.ts
import { selectAccounts } from "@/lib/supabase/accounts/selectAccounts";
const accounts = await selectAccounts();

Directory Structure

lib/supabase/
├── serverClient.ts              # Supabase client instance
├── accounts/
│   ├── selectAccounts.ts
│   ├── insertAccount.ts
│   └── updateAccount.ts
├── account_api_keys/
│   ├── selectAccountApiKeys.ts
│   ├── insertApiKey.ts
│   └── deleteApiKey.ts
├── account_organization_ids/
│   ├── getAccountOrganizations.ts
│   └── addAccountToOrganization.ts
└── [table_name]/
    └── [action][TableName].ts

Naming Conventions

  • select[TableName].ts - Basic SELECT queries
  • insert[TableName].ts - INSERT queries
  • update[TableName].ts - UPDATE queries
  • delete[TableName].ts - DELETE queries
  • get[Descriptive].ts - Complex queries with joins

Pattern

import supabase from "@/lib/supabase/serverClient";
import type { Tables } from "@/types/database.types";

/**
 * Select rows from table_name with optional filters.
 */
export async function selectTableName({
  filter,
}: {
  filter?: string;
} = {}): Promise<Tables<"table_name">[] | null> {
  let query = supabase.from("table_name").select("*");

  if (filter) {
    query = query.eq("column", filter);
  }

  const { data, error } = await query;

  if (error) {
    console.error("Error fetching table_name:", error);
    return null;
  }

  return data || [];
}

Code Principles

  • SRP (Single Responsibility Principle): One exported function per file. Each file should do one thing well.
  • DRY (Don't Repeat Yourself): Extract shared logic into reusable utilities.
  • KISS (Keep It Simple): Prefer simple solutions over clever ones.
  • All API routes should have JSDoc comments
  • Run pnpm lint before committing

Terminology

Use "account" terminology, never "entity" or "user". All entities in the system (individuals, artists, workspaces, organizations) are "accounts". When referring to specific types, use the specific name:

  • account_id, "artist", "workspace", "organization"
  • entity_id, "entity", "user"

API Response Shapes

Keep response bodies flat — put fields at the root level, not nested inside a data wrapper:

// ✅ Correct — flat response
{ success: true, connectors: [...] }

// ❌ Wrong — unnecessary nesting
{ success: true, data: { connectors: [...] } }

Test-Driven Development (TDD)

CRITICAL: Always write tests BEFORE implementing new features or fixing bugs.

TDD Workflow

  1. Write failing tests first - Create tests in lib/[domain]/__tests__/[filename].test.ts that describe the expected behavior
  2. Run tests to verify they fail - pnpm test path/to/test.ts
  3. Implement the code - Write the minimum code needed to make tests pass
  4. Run tests to verify they pass - All tests should be green
  5. Refactor if needed - Clean up while keeping tests green

Test File Location

Tests live alongside the code they test:

lib/
├── chats/
│   ├── __tests__/
│   │   └── updateChatHandler.test.ts
│   ├── updateChatHandler.ts
│   └── validateUpdateChatBody.ts

Test Pattern

import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";

// Mock dependencies
vi.mock("@/lib/networking/getCorsHeaders", () => ({
  getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })),
}));

describe("functionName", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  describe("successful cases", () => {
    it("does something when condition is met", async () => {
      // Arrange
      vi.mocked(dependency).mockResolvedValue(mockData);

      // Act
      const result = await functionName(input);

      // Assert
      expect(result.status).toBe(200);
    });
  });

  describe("error cases", () => {
    it("returns 400 when validation fails", async () => {
      // Test error handling
    });
  });
});

When to Write Tests

  • New API endpoints: Write tests for all success and error paths
  • New handlers: Test business logic with mocked dependencies
  • Bug fixes: Write a failing test that reproduces the bug, then fix it
  • Validation functions: Test all valid and invalid input combinations

Authentication

Never use account_id in request bodies or tool schemas. Always derive the account ID from authentication:

  • API routes: Use validateAuthContext() (supports both x-api-key and Authorization: Bearer tokens)
  • MCP tools: Use extra.authInfo via resolveAccountId()

Both API keys and Privy access tokens resolve to an accountId. Never accept account_id as user input.

API Routes

CRITICAL: Always use validateAuthContext() for authentication. This function supports both x-api-key header AND Authorization: Bearer token authentication. Never use getApiKeyAccountId() directly in route handlers - it only supports API keys and will reject Bearer tokens from the frontend.

import { validateAuthContext } from "@/lib/auth/validateAuthContext";

const authResult = await validateAuthContext(request, {
  accountId: body.account_id,        // Optional: for account_id override
  organizationId: body.organization_id, // Optional: for org context
});

if (authResult instanceof NextResponse) {
  return authResult;
}

const { accountId, orgId, authToken } = authResult;

validateAuthContext handles:

  • Both x-api-key and Authorization: Bearer authentication
  • Account ID override validation (org keys can access member accounts)
  • Organization access validation

MCP Tools

CRITICAL: Never manually extract accountId from extra.authInfo (e.g. authInfo?.extra?.accountId). Always use resolveAccountId() — it handles validation, org-key overrides, and access control in one place.

import { resolveAccountId } from "@/lib/mcp/resolveAccountId";
import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey";

const authInfo = extra.authInfo as McpAuthInfo | undefined;
const { accountId, error } = await resolveAccountId({
  authInfo,
  accountIdOverride: undefined,
});

if (error) {
  return getToolResultError(error);
}

if (!accountId) {
  return getToolResultError("Failed to resolve account ID");
}

This ensures:

  • Callers cannot impersonate other accounts
  • Authentication is always enforced
  • Account ID is derived from validated credentials
  • Frontend apps using Bearer tokens work correctly

Input Validation

All API endpoints should use a validate function for input parsing. Use Zod for schema validation.

Pattern

Create a validate<EndpointName>Body.ts or validate<EndpointName>Query.ts file:

import { NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { z } from "zod";

// Define the schema
export const createExampleBodySchema = z.object({
  name: z.string({ message: "name is required" }).min(1, "name cannot be empty"),
  id: z.string().uuid("id must be a valid UUID").optional(),
});

// Export the inferred type
export type CreateExampleBody = z.infer<typeof createExampleBodySchema>;

/**
 * Validates request body for POST /api/example.
 *
 * @param body - The request body
 * @returns A NextResponse with an error if validation fails, or the validated body if validation passes.
 */
export function validateCreateExampleBody(body: unknown): NextResponse | CreateExampleBody {
  const result = createExampleBodySchema.safeParse(body);

  if (!result.success) {
    const firstError = result.error.issues[0];
    return NextResponse.json(
      {
        status: "error",
        missing_fields: firstError.path,
        error: firstError.message,
      },
      {
        status: 400,
        headers: getCorsHeaders(),
      },
    );
  }

  return result.data;
}

Usage in Handler

const validated = validateCreateExampleBody(body);
if (validated instanceof NextResponse) {
  return validated;
}
// validated is now typed as CreateExampleBody

Naming Convention

  • validate<Name>Body.ts - For POST/PUT request bodies
  • validate<Name>Query.ts - For GET query parameters

MCP Tools Architecture (DRY with API Endpoints)

MCP tools and REST API endpoints share business logic through domain-specific functions. This ensures DRY compliance and consistent behavior across all interfaces.

Directory Structure

lib/mcp/tools/
├── index.ts                        # registerAllTools() - central registration
├── [domain]/
│   ├── index.ts                    # registerAll[Domain]Tools()
│   └── register[ToolName]Tool.ts   # Individual tool registration

DRY Pattern: Shared Logic Between MCP Tools and API Routes

Both MCP tools and API routes should use the same domain functions:

┌─────────────────────────────────────┐     ┌─────────────────────────────────────┐
│  API Route Handler                   │     │  MCP Tool Handler                    │
│  (app/api/endpoint/route.ts)        │     │  (lib/mcp/tools/*/register*.ts)     │
│                                      │     │                                      │
│  validateRequest() ──┐               │     │  Extract args from schema ──┐       │
│                      ↓               │     │                             ↓       │
│              ┌───────────────────────┴─────┴───────────────────────┐             │
│              │        Shared Domain Logic (lib/[domain]/)          │             │
│              │  - buildParams functions (auth/access control)      │             │
│              │  - process functions (business logic)               │             │
│              │  - Supabase queries (lib/supabase/[table]/)        │             │
│              └───────────────────────┬─────┬───────────────────────┘             │
│                      ↓               │     │                             ↓       │
│  Return NextResponse                 │     │  Return getToolResultSuccess()     │
└─────────────────────────────────────┘     └─────────────────────────────────────┘

Examples of DRY Implementation

Feature API Route MCP Tool Shared Logic
Get Chats GET /api/chats get_chats buildGetChatsParams, selectRooms
Get Pulses GET /api/pulses get_pulses buildGetPulsesParams, selectPulseAccounts
Create Artist POST /api/artists create_new_artist createArtistInDb, copyRoom
Compact Chats POST /api/chats/compact compact_chats processCompactChatRequest

Creating a New MCP Tool (Following DRY)

  1. Identify shared logic - Check if an API endpoint exists with reusable functions
  2. Create the tool file - lib/mcp/tools/[domain]/register[ToolName]Tool.ts
  3. Import shared functions - Use the same domain logic as the API route
  4. Register in index - Add to lib/mcp/tools/[domain]/index.ts

Tool Registration Pattern

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey";
import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess";
import { getToolResultError } from "@/lib/mcp/getToolResultError";
// Import shared domain logic
import { sharedDomainFunction } from "@/lib/[domain]/sharedDomainFunction";

const toolSchema = z.object({
  param: z.string().describe("Description for the AI."),
});

export function registerToolNameTool(server: McpServer): void {
  server.registerTool(
    "tool_name",
    {
      description: "Tool description for the AI.",
      inputSchema: toolSchema,
    },
    async (args, extra: RequestHandlerExtra<ServerRequest, ServerNotification>) => {
      const authInfo = extra.authInfo as McpAuthInfo | undefined;
      const accountId = authInfo?.extra?.accountId;
      const orgId = authInfo?.extra?.orgId ?? null;

      if (!accountId) {
        return getToolResultError("Authentication required.");
      }

      // Use shared domain logic (same as API route)
      const result = await sharedDomainFunction({ accountId, orgId, ...args });

      if (!result) {
        return getToolResultError("Operation failed");
      }

      return getToolResultSuccess(result);
    },
  );
}

Key Utilities

  • getToolResultSuccess(data) - Wrap successful responses
  • getToolResultError(message) - Wrap error responses
  • resolveAccountId({ authInfo, accountIdOverride }) - Resolve account from auth

Constants (lib/const.ts)

All shared constants live in lib/const.ts:

  • INBOUND_EMAIL_DOMAIN - @mail.recoupable.com (where emails are received)
  • OUTBOUND_EMAIL_DOMAIN - @recoupable.com (where emails are sent from)
  • SUPABASE_STORAGE_BUCKET - Storage bucket name
  • Wallet addresses, model names, API keys