The CRM is four record types, five dimensions, one board and one rule: a client is its own partition, everything that happens is a node belonging to that client, and every chip on the board is rendered from those nodes. Nothing is typed twice, nothing is a list inside a record — the board, the client page and the deal page all read the same nodes.
The records
| Record | Where it lives | The node carries | The content carries |
|---|---|---|---|
Crm/Client |
a top-level node — the client's own partition (/PearlTechnology) |
name, description (tagline), icon |
website, industry · country · segment (dimension members), owner (a user), body (the workspace page, markdown) |
Crm/Opportunity |
{Client}/{Deal} — a direct child |
name (the deal), description (the one-line summary) |
state (Open · Won · Lost · OnHold), offering (member), amount + currency (member), probability, expectedClose, owner, nextStepClient, nextStepSystemorph, nextStepDate |
Crm/Interaction |
{Client}/{Interaction} — a direct child |
name (the chip's label), description (what happened) |
stage (a Crm/Stage member), status (Completed ✓ · Current ● · Planned ○), date (calendar), order, note, opportunity (the deal it advances, or none), contact |
Crm/Contact |
{Client}/{Person} — a direct child |
name, description (role line) |
role, organisation, email, phone, profile, doNotContact, consent, notes |
Essentials/Email (not a CRM type) |
{Client}/{Deal}/{Mail} — outgoing mail, under its deal |
name (what the mail is), description |
the generic message record of MeshWeaver Essentials: to, cc, subject, body (markdown), attachments (what travels as files — the document exported to PDF), relatedTo (the deal), approvers (user ids, comma-separated), requiredApprovals (the quorum — 2 of 3); status, sentAt, sentBy are stamped by Send. The approvals are the platform's _Approval satellites, never fields — see the /email skill |
Dimensions — Crm/Stage, Crm/Industry, Crm/Country, Crm/Segment, Crm/Offering,
Crm/Currency — are node types whose members are nodes (Crm/Stage/Workshop,
Crm/Country/CH). A record references a member by path, and the Edit page renders a real picker
for it. The members shipped in this repo are the initial values; adding one is one JSON file,
landed by PR. Nothing in code enumerates them — every page reads them live.
Documents — proposals, emails, cases, decks — stay where they are: ordinary nodes beside the records.
The client page lists them; nothing has to move. A client partition keeps everything a Space had:
its own schema, routing, _Access grants, threads, files. Retyping the root to Crm/Client adds the
structured facts; it removes nothing. Access is per client partition, as before — the board shows
each viewer the clients they can read.
Adding a record — the "+" is on the section that lists it
Every record SECTION on a client, deal or offer page offers what it lists — the deals, contacts and interactions on a client, the offers and correspondence on a deal, the questions on an offer. The "+" sits in the section's own header and opens the Create form with the type already chosen and the new record already aimed at the right place — a question lands under the offer whose page you were reading, a deal under its client. You never pick the type out of a list of ninety.
The promise is about those SECTIONS, not about every list on every page. A view that summarises or aggregates offers nothing, and none of these is an omission:
- The board's pipeline spans every client, so there is no one client a new deal would belong to. Add it on the client's own page.
- The board's roster lists clients, and a client is a whole partition rather than a record — it is created the way a space is, not by filling in a form.
- The board's analysis views (Pipeline, Activity, Metrics) group and count what already exists.
- A client's card on the board lists that client's deals as a summary of the client page's own Opportunities section. Open the client and add it there.
One thing the "+" cannot do yet. The Create form asks for a name and a place; it does not fill in a record's own fields. So an offer added from a deal's page belongs to that deal because of where it sits, and everything else — status, amount, what it covers, the dates — is set on the offer's Edit page afterwards. Naming the deal explicitly there is still worth doing when the offer is later moved.
Until 2026-09-12 only the generic Documents catalog carried a "+", so every record type had to be created through an agent or a hand-written node — which made the capability invisible to anyone who did not already know the type existed. The offer page was the sharpest case: it announced that open questions block the client's decision and gave no way to record one.
The journey — interactions, stages, one bar
An interaction is a node. A meeting, a workshop, documentation sent, an offer made, a wait for
the client's answer — each is a Crm/Interaction under the client, one chip on the bar. Its stage
is a Crm/Stage member whose category decides the colour: Prospecting (yellow — Prospect,
Meeting, ImpulseSeminar, Documentation, CommercialIndication, Workshop, ProjectProposal,
Nda), Engagement (green — DrivingLessons, Pilot, Consulting, Project, Subscription,
Delivery, Client, Renewal), Waiting (gray — AwaitFollowUp, Dormant), Terminal (gray —
Closed). Its status is Completed ✓, Current ● or Planned ○. The rendering rules are the
Journey Model.
Which bar does an interaction belong to? One with no opportunity is RELATIONSHIP-level — the
first chip (Prospect, Client; the members carry a relationship flag), a first meeting, an
impulse seminar, an NDA — and draws on the client's bar. One that names a deal draws on that deal's
bar, captioned with the deal's name. PartnerRe reads ✓ Client on the client, then
AI Consulting: ● Consulting and ESL Lifecycle Platform: ● Project Proposal → ✓ Meeting (24 Aug)
— two deals, two bars, one client.
Dates are calendar dates (yyyy-MM-dd), never instants: a workshop on 22 August is on 22 August
in every time zone. The chip renders it as (22 Aug). A note rides on the chip only when it is a qualifier of at most
24 characters ((to send)); a longer note shows on the interaction's card and in the chip's tooltip,
and a chip is never wider than 280 px — the rest is an ellipsis, the tooltip has it all. Repeats of one stage are numbered by the bar
itself — Documentation (1st), (2nd) — never stored.
The states of a deal
| State | Meaning | How it is reached |
|---|---|---|
| Open | being pursued | the default; Reopen from On hold |
| Won | signed — the engagement runs or is delivered | Close — won: the deal's Current interactions become Completed |
| Lost | lost or withdrawn | Close — lost: same |
| OnHold | parked by either side | Put on hold: the interactions are kept as they stand |
The fine-grained position is the bar; the state is the coarse lifecycle the pipeline reports on. A
Waiting interaction (AwaitFollowUp) is a Current chip whose ball is on the client's side — the
board orders such deals after the ones where the next deliverable is ours.
Who records what — the process, step by step
- A new prospect. Create the client partition — a top-level node
nodeType: "Crm/Client"with{ "$type": "ClientContent", … }(see/crm) — and its first interaction:Prospect, Current. Grant the team on{Client}/_Accessas you would on a Space. Record the first contact(s). - A deal appears. Create
{Client}/{Deal}withstate: Open, theoffering, andnextStepSystemorph— every open deal names what WE do next. - Something happened. On the deal page, Record interaction (pick the stage, the date, an optional note): a Current wait on this deal becomes Completed, a Planned interaction of that stage is promoted in place, otherwise a new interaction node is created under the client, referencing the deal. Update the next steps on the deal. Parallel Current interactions are legitimate (Consulting running while a Project Proposal is out).
- Waiting. When the ball moves to the client, record
AwaitFollowUpand setnextStepClient— andnextStepDatewhen there is one (a meeting on the 2nd, a decision by October). The board sorts dated next steps first. - Decision. Close — won (then record the engagement —
Pilot,Project,Subscription,Consulting— as the deal's Current interaction, andClientas a relationship interaction), Close — lost, or Put on hold. - The board (/Crm/Portfolio) is never edited: it reads. The legend, the ordering rule and the next steps come from the records. It holds no list of its own — it DECLARES its sets (the open pipeline, the client roster) as queries the GUI runs, so the board shows exactly what the reader may read. Three analysis views sit in its node menu: Numbers (what the pipeline adds up to), Pipeline by client (open deals grouped and counted) and Recent activity (every interaction, newest first). Robert's hand-authored board is the specification it was built to.
Field edits (amount, expected close, next steps, facts) go through the node's Edit area —
the framework's node-bound editor with real pickers for every dimension, one source of truth, no
save button. Actions (record, close, hold) are buttons on the deal page, each a short list of
node writes. Over MCP: create an interaction node, patch a deal's field, or /crm for the shapes.
How the records came to exist
The records were not typed in. The hand-authored board (/Portfolio) and each client's own
documents were migrated into records during August 2026, in place — a retype keeps a node's
path, its history and every inbound link, which is why a promoted offer is still found by the links
that pointed at the document.
What the migration produced, and still holds:
- each client section became a
Crm/Clientpartition root, its body kept as the workspace page; - each chip on the hand-drawn bar became one
Crm/Interaction, resolved to itsCrm/Stagemember; - each section's next steps became the deal,
{Client}/{Deal}; - people with a page of their own became
Crm/Contact; - the named commercial documents became
Crm/Offer, carrying their full text into the offer's body — a promotion that loses the document is worse than no migration at all.
There is no migration NodeType any more. It was removed once the work was done: a compiled
control plane that lives in the module forever is a node that can be armed forever, and on
2026-08-30 exactly that — an armed request meeting a stale assembly — stripped four client documents
before it was caught. One-off data work is now done with an executable Code node, run once, by a
person, verifying each write by reading it back. The reference implementation is
rbuergi/Script/promote-offers, which promoted the four client offers with every body intact.
If another client is onboarded, copy that script, name the documents, run it as a dry run, read the plan, then run it for real. The plan is not the result: check the node afterwards.
Before you change data: check what the portal is actually running
Any bulk change writes to real client partitions, so the thing worth checking first is that the portal runs the code you think it does.
On 2026-08-30 a migration ran against a stale build while its type reported compilationStatus: Ok and its compiled sources matched the repo exactly. It stripped the text out of four client
documents, one of them a 7,000-word commercial offer, and one document's text could not be
recovered. The type's own report is not proof.
Nor is any other field on it: comparing the type against its published Release looks like the answer and is not — releases lag routinely, and a type whose release pointer was three versions and a framework identity out of date was serving its pages perfectly the same afternoon.
The check that works is behavioural. Open a real instance of the type and look for what your change does:
- if the page shows only the generic views every node has (
Edit,Versions,Files), the type is not loading at all; - if it renders, read it for the thing you changed.
And nothing is migrated until it is read back. After a bulk change, open the nodes and confirm the content you meant to carry is on them. A plan cannot show you an empty result; only the node can.
Offers, and the questions they raise
An offer is a record, not a document: {Client}/{Offer} carries its status
(Draft · Sent · Accepted · Declined · Withdrawn), the amount and what it covers, the day it went
out and the day it lapses. The prose stays — it is the offer's body, and the pricing documents stay
beside it — but the handful of fields make it answerable: what is out with a client right now,
what expires this month, what did they decline and why.
- A revision is a NEW offer that
supersedesthe previous one. "We offered 60k, then 50k" is two records with two dates; editing one in place would erase that. - Expiry is derived, never stored. A Sent offer past its
validUntilreads as Expired; the record is untouched, so nothing has to run to keep it honest. - An offer names its deal by field, not by where it sits — the same way an interaction does. A document promoted into an offer keeps the path it already had, so every link that pointed at it still resolves, and the deal page finds it by filtering its client's offers.
- Questions hang off the offer. Each one the client raises is a
Crm/Questionwith our answer. An OPEN question blocks their decision, so an offer can say "3 of 5 answered" and the deals waiting on US become visible instead of being buried in a mail thread.
Configuring the process
The steps a deal is expected to walk are a node, not code: Crm/Workflow/{Id} holds an ordered
list of steps, each naming a Crm/Stage member, whose move it is (us or the client) and how many
days after the deal opens it is due. Three ship — Standard,
Pilot and Subscription renewal.
- A deal follows the workflow its OFFERING prescribes (
Crm/Offering/Pilot→ the Pilot workflow), so binding a process to a kind of deal is one edit rather than one per deal. A deal can override it on its own Edit page when it genuinely differs. - The bar shows the whole process — what was recorded, then the steps still to come as planned
(
○) chips carrying their due date — and the deal page shows where it stands ("2/5 steps · next Workshop, due 22 Aug ⚠️ overdue"). - Editing a workflow re-plans every deal that follows it, on the next frame. Nothing is stored; the plan is a projection of the workflow and the interactions.
- Adding a step is one edit to the workflow node. Adding a stage is still one member node
under
Crm/Stage/— a workflow can only name stages that exist.
A deal whose offering prescribes nothing has no plan, and renders exactly as it did before.
Numbers, and what they are allowed to claim
Numbers (/Crm/Portfolio/area/Metrics) is the one view that adds things up. Everything on it is counted over the deals you can see — the read runs under your identity, so a total is never larger than your access — and dates are read in your own time zone.
What it will not do matters more than what it will:
- It never adds two currencies together. There is no rate table here, so a single "total" over CHF and EUR would be a number nobody could check. One row per currency.
- A figure over part of the set says so. The weighted pipeline reads "over 1 of 2 — the rest carry no estimate" rather than presenting a quarter of an answer as a whole one.
- An unpriced deal counts as nothing, not zero — otherwise a portfolio nobody has priced would read as a portfolio worth nothing. The count of unpriced deals is printed instead.
- Before anything is decided there is no win rate, not 0% — which would read as having lost everything.
What needs attention, on a deal
A deal page shows an attention block when — and only when — something is wrong with it: an unanswered question on an offer (blocking), workflow steps past due, a next step overdue or missing, an expected close that has passed, a deal nothing has been recorded against, or one that has been quiet for thirty days. Unpriced and unowned are quiet notes.
These are rules over your records, not a model's opinion, and each line prints the fact that triggered it. A score of 63 cannot be argued with; "3 unanswered questions on an offer" can be, and is the sentence that gets someone to act. A healthy deal shows nothing at all — a permanent "Health: Good" panel teaches people to stop reading the panel.
Capturing activity from mail
The Gmail/M365 connector asks this module one question: what is this message about? The answer follows one rule — a wrong attribution is worse than no attribution. A mail nobody captured costs a minute; a mail filed against the wrong client puts one client's business on another client's page, and it is found by the person it was shown to.
So it refuses rather than guesses. Two clients on a thread: nothing is attributed, and both are named so you can decide. Two of your contacts at one client: the client is named, the person is not. Two open deals: the deal is left to you. An address at a personal mail provider never identifies a client — otherwise one client who happens to use Gmail would collect every stranger's Gmail — though that same person's exact address still resolves to them. Internal mail between colleagues attributes to nobody, and a mail you sent is attributed to whoever received it.
Nothing is written automatically. You are offered a proposal, because the cost of a wrong record is paid by whoever reads it later.
For developers: reading data
Every live read in this module goes through CrmQueries.Synced, never hub.GetQuery directly.
It appends the content projection, because on a synced query content is the one column you have
to ask for — omit it and every ContentAs<T>() returns null, every .Where(c is not null) drops
the row, and the page renders as if there were no data at all. Nothing errors. This module shipped
that twice; the helper exists so a call site cannot repeat it.
For agents
The /crm skill (Crm/Skill/crm) carries the exact node shapes and the
process above as a procedure; the CRM assistant (Crm/Agent/crm-assistant)
records interactions, opens and closes deals, sets next steps and adds contacts in conversation —
writing only the fields the process names, never a stage outside the members.
A client OWNS its partition
Crm/Client declares ownsPartition: true — the shape Space and User use. A TOP-LEVEL
instance of an owning type provisions its own Postgres schema on create and makes the creator its
Admin; the partition write guard REFUSES creating a top-level node of any other type. So /crm's
"new client" create (empty namespace) is legal only because of this flag — without it a new
client could only ever arrive by retyping an existing Space, which is what the migration did.
Outgoing mail is approved before it goes — and it is not a CRM record
A mail to a client is an Essentials/Email record filed under its deal — the platform's
generic message type, not one of this module's. Its page is read-only and formatted the way the
mail will read (the envelope, the body, the attachments); the words are written on its Edit form.
The gate is the platform's cross-cutting Approvals: name approvers (user ids) and a
requiredApprovals quorum on the message (for PartnerRe, two of mkleiner, rsalzmann and
rbuergi), press Request approvals, and each approver gets an ordinary approval request —
decided on the message page or on the Approval Desk. Send appears only once the quorum has
approved the text as it stands: an approval counts only if it was decided after the message
was last saved, so an edit lapses it and the approvers are asked again; a rejection blocks until
the text changes. Sending is recorded (the moment and the person); delivery is the mail
connector's job, and the document travels as the PDF named under attachments, never pasted into
the mail. The deal page lists the messages under Correspondence; the setup, field by field, is
the /email skill of Essentials.