This file provides guidance to coding agents like Claude Code (claude.ai/code) and OpenCode when working with code in this repository.
Always commit and push changes after completing a task. Follow these rules:
- After making code changes, always commit with a descriptive message
- Push commits to the current feature branch
- NEVER push directly to
mainortestbranches - always use feature branches and PRs - Before pushing, verify the current branch is not
mainortest - Open PRs against the
testbranch, notmain - After pushing, check if a PR exists for the branch. If not, create one with
gh pr create --base test - After creating a PR, always wait for explicit user approval before merging. Never merge PRs autonomously.
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 testThen checkout main, pull latest, and create your feature branch from there.
This is the only time you should push directly to test.
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- 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 integrationslib/emails/- Email handling (Resend)lib/supabase/- Database operationslib/trigger/- Trigger.dev task triggerslib/x402/- Payment middleware utilities
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:
- First check if a function already exists in
lib/supabase/[table_name]/ - If not, create a new function in
lib/supabase/[table_name]/first - 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();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
select[TableName].ts- Basic SELECT queriesinsert[TableName].ts- INSERT queriesupdate[TableName].ts- UPDATE queriesdelete[TableName].ts- DELETE queriesget[Descriptive].ts- Complex queries with joins
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 || [];
}- 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 lintbefore committing
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"
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: [...] } }CRITICAL: Always write tests BEFORE implementing new features or fixing bugs.
- Write failing tests first - Create tests in
lib/[domain]/__tests__/[filename].test.tsthat describe the expected behavior - Run tests to verify they fail -
pnpm test path/to/test.ts - Implement the code - Write the minimum code needed to make tests pass
- Run tests to verify they pass - All tests should be green
- Refactor if needed - Clean up while keeping tests green
Tests live alongside the code they test:
lib/
├── chats/
│ ├── __tests__/
│ │ └── updateChatHandler.test.ts
│ ├── updateChatHandler.ts
│ └── validateUpdateChatBody.ts
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
});
});
});- 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
Never use account_id in request bodies or tool schemas. Always derive the account ID from authentication:
- API routes: Use
validateAuthContext()(supports bothx-api-keyandAuthorization: Bearertokens) - MCP tools: Use
extra.authInfoviaresolveAccountId()
Both API keys and Privy access tokens resolve to an accountId. Never accept account_id as user input.
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-keyandAuthorization: Bearerauthentication - Account ID override validation (org keys can access member accounts)
- Organization access validation
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
All API endpoints should use a validate function for input parsing. Use Zod for schema validation.
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;
}const validated = validateCreateExampleBody(body);
if (validated instanceof NextResponse) {
return validated;
}
// validated is now typed as CreateExampleBodyvalidate<Name>Body.ts- For POST/PUT request bodiesvalidate<Name>Query.ts- For GET query parameters
MCP tools and REST API endpoints share business logic through domain-specific functions. This ensures DRY compliance and consistent behavior across all interfaces.
lib/mcp/tools/
├── index.ts # registerAllTools() - central registration
├── [domain]/
│ ├── index.ts # registerAll[Domain]Tools()
│ └── register[ToolName]Tool.ts # Individual tool registration
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() │
└─────────────────────────────────────┘ └─────────────────────────────────────┘
| 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 |
- Identify shared logic - Check if an API endpoint exists with reusable functions
- Create the tool file -
lib/mcp/tools/[domain]/register[ToolName]Tool.ts - Import shared functions - Use the same domain logic as the API route
- Register in index - Add to
lib/mcp/tools/[domain]/index.ts
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);
},
);
}getToolResultSuccess(data)- Wrap successful responsesgetToolResultError(message)- Wrap error responsesresolveAccountId({ authInfo, accountIdOverride })- Resolve account from auth
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