Records to CRM in three edits

We have made the abstract case for the entity-view kit: one EntityConfig, nine views, three edits to clone. This post is the concrete version. We are going to turn the records pattern into a small CRM — contacts with deal amounts, pipeline stages, and follow-up dates — and the entire build is the three edits AGENTS.md promises.

Records → CRM in three edits.

Edit one: the schema block

Open convex/schema.ts, copy the records table block, and rename it contacts. Keep the shape close: a title-like field for the contact's name, a status field for the pipeline stage, a date, a number, tags, and notes. On Convex this is a defineTable call with validators; on the Supabase variant it is a CREATE TABLE plus RLS policies in schema.sql — and because RLS already is the backend there, this edit and the next one collapse into one.

Edit two: the backend module

Copy convex/records.ts and recordsShared.ts to contacts.ts and contactsShared.ts, then adjust the validators and the table name. You get the four endpoints the views expect — list, create, patch, delete — scoped to the signed-in user's workspace, same as the original. Nothing clever happens here, which is the point: the backend for a new domain is a rename plus a validator pass, not a design session.

Edit three: the CONFIG

Copy the records feature folder to features/contacts and rewrite the one object that matters. Here is a real config for a sales pipeline:

const STAGES = [
  { value: "lead", label: "Lead", tone: "neutral" },
  { value: "qualified", label: "Qualified", tone: "info" },
  { value: "proposal", label: "Proposal", tone: "warning" },
  { value: "won", label: "Won", tone: "success" },
  { value: "lost", label: "Lost", tone: "danger" },
];

const CONFIG: EntityConfig = {
  labels: ["Contact", "Contacts"],
  titleKey: "name",
  statusKey: "stage",
  dateKey: "followUp",
  amountKey: "dealValue",
  fields: [
    { key: "name", label: "Name", type: "text", required: true },
    { key: "company", label: "Company", type: "text" },
    { key: "stage", label: "Stage", type: "select", options: STAGES },
    { key: "dealValue", label: "Deal value", type: "number" },
    { key: "followUp", label: "Follow-up", type: "date" },
    { key: "tags", label: "Tags", type: "tags" },
    { key: "notes", label: "Notes", type: "longtext" },
  ],
};

Register the feature with one import and one line, and the routes and nav wire themselves.

What the nine views mean now

This is where the role keys earn their keep, because each one you filled in just became a CRM feature you did not build. statusKey means the kanban board is a pipeline: columns are stages, and dragging a card from Qualified to Proposal is a stage change persisted through onPatch. amountKey means the stats view shows deal totals — overall and broken down per stage, so the Won column's aggregate is your closed revenue. dateKey means timeline and calendar are follow-up views: the calendar shows who you owe a call this month, and month paging is already handled.

  • Board — pipeline stages, drag to advance a deal
  • Stats — deal totals per stage via amountKey
  • Calendar and timeline — follow-ups via dateKey
  • Table, list, grid — the everyday contact browser, sorting included
  • Form — generated from the fields array, tones and all

We omitted imageKey and positionKey, so the gallery and canvas views simply are not offered — availableViews(CONFIG) drops them without any configuration. Add an avatar field later and the gallery appears.

Why this is the recipe

Total output: a schema block, a renamed backend module, and one declarative config. For a human that is well under an hour; for an agent it is a few short, hard-to-get-wrong generations instead of thousands of lines of view code. And because the views are themed by the shell's CSS variables, your CRM already matches whichever of the 100 designs the app runs in.

The data model is the configuration — fill a role key, gain a feature.

Next time someone asks for projects, invoices, or job applications, the answer is the same three edits with different nouns.

← All posts