Blueprint · CRM

CRM

Contacts, companies, a deal pipeline, an activity log, and follow-up tasks for a small services firm.

Published by Croft · version 1 · crmsalespipelinecontactsdealsfollow-ups

What this needs

Requires no connections. Runs on demand, when called directly.

Record streams

  • activities (write)
  • companies (write)
  • contacts (write)
  • crm_tasks (write)
  • deals (write)

About this blueprint

A working CRM for a small services firm. Companies and the people who work at them link to each other, so a company record shows every contact, every open deal, and the last few things that happened. Deals move across a six stage pipeline from lead through qualified, proposal and negotiation to won or lost, dragged between columns on a board. Each stage carries a probability, so the board reports both raw pipeline and weighted value per column and the header tiles show what the quarter is really worth rather than a best case total. Every call, email, meeting and note lands in a shared activity log against its company, contact and deal, and follow-up tasks carry a due date, an owner and a priority, with anything past its date flagged as overdue so nothing quietly slips.

Under the hood it is one callable workflow writing five append-only record streams: companies, contacts, deals, activities and crm_tasks. Every write is a full snapshot of a row keyed by a stable id, and a removal appends a tombstone rather than erasing history, so the full audit trail of how a deal moved through the pipeline survives. The page reads current state with a newest-row-wins window query and reconciles twice after a burst of writes. Dragging a card is a single save that rewrites the stage and resets the probability to that stage's default; a drop back into the same column writes nothing at all. Filtering by owner hides cards rather than removing them, which keeps the board's own ordering honest.

It suits an agency, a consultancy, a fabricator, a broker, or any firm of a handful of people selling a considered product where the real work is remembering who to call back and when. There are no seats to buy and no per contact limits. Install it, add your companies, and the pipeline is the only page most of the team needs open. The deals stream uses plain field names, so a sales dashboard or any other app in the same workspace can read straight from it without a mapping layer.

How it works

crm
Build row
Build row
if entity === "contacts"
if entity === "contacts"
records.append("contacts", […])
Append Records
if entity === "companies"
if entity === "companies"
records.append("companies", […])
Append Records
if entity === "deals"
if entity === "deals"
records.append("deals", […])
Append Records
if entity === "activities"
if entity === "activities"
records.append("activities", […])
Append Records
records.append("crm_tasks", […])
Append Records
done
Source — 1 file, show

index.ts

import { callable, workflow, z } from "@croft/sdk";

/**
 * CRM — the write engine behind the small-firm CRM app.
 *
 * Every entity lives in an append-only record stream as an event log: each
 * append is a full snapshot of the row, and the newest row per `id` wins.
 * Removals append a tombstone (`_op: "gone"`) rather than erasing history.
 * The artifact page reads current state with a `row_number()` window query.
 *
 * `_op` is "live"/"gone" and never the word "delete", because `records.query`
 * rejects COPY EXPORT INSTALL LOAD ATTACH CREATE INSERT UPDATE DELETE PRAGMA
 * as whole words anywhere in the SQL text — string literals included.
 *
 * The appends are written out one stream at a time, rather than through a
 * computed stream name, so the analyzer can name every stream this workflow
 * touches and an install plan can list them.
 */

const Input = z.object({
  entity: z
    .enum(["contacts", "companies", "deals", "activities", "crm_tasks"])
    .describe("Which record stream to write"),
  action: z
    .enum(["save", "remove"])
    .describe("save writes a full snapshot of the row; remove appends a tombstone"),
  id: z
    .string()
    .describe("Stable id for the row. Reuse it to update; omit on create to mint one.")
    .optional(),
  data: z
    .record(z.string(), z.any())
    .describe(
      "Flat field map. companies{name,industry,website,size,city,owner,notes}; contacts{company_id,name,title,email,phone,owner,notes}; deals{name,company_id,contact_id,stage,value,probability,expected_close,owner,source,notes} where stage is lead, qualified, proposal, negotiation, won or lost and probability is a percentage from 0 to 100; activities{kind,subject,body,company_id,contact_id,deal_id,happened_on,owner} where kind is call, email, meeting or note; crm_tasks{title,due_on,done,owner,company_id,contact_id,deal_id,priority} where done is true or false and priority is low, medium or high.",
    )
    .optional(),
});

export default workflow(
  "crm",
  {
    trigger: callable({
      description:
        "Create, update, or remove a contact, company, deal, activity, or follow-up task in the CRM.",
      input: Input,
    }),
    connections: [],
    description:
      "Write engine for the CRM app: companies and the contacts who work at them, a weighted deal pipeline with stages and expected close dates, a logged activity history, and follow-up tasks with due dates.",
  },
  async (ctx) => {
    const { entity, action, id, data } = ctx.input;

    const row = await ctx.step("Build row", () => {
      const rowId = id ?? `${entity.slice(0, 3)}_${Math.random().toString(36).slice(2, 10)}`;
      return {
        ...(action === "remove" ? {} : (data ?? {})),
        id: rowId,
        _op: action === "remove" ? "gone" : "live",
        _ts: new Date().toISOString(),
      };
    });

    if (entity === "contacts") {
      await ctx.records.append("contacts", [row]);
    } else if (entity === "companies") {
      await ctx.records.append("companies", [row]);
    } else if (entity === "deals") {
      await ctx.records.append("deals", [row]);
    } else if (entity === "activities") {
      await ctx.records.append("activities", [row]);
    } else {
      await ctx.records.append("crm_tasks", [row]);
    }

    return { id: row.id, entity, action, ts: row._ts };
  },
);

Want this running in your own workspace?

Installing recompiles this exact version in your workspace, lands it paused, and walks you through granting whatever it needs above. No account yet? Installing creates one.