Specification · crm @ 0.5

crm — Relationship Pursuit · Area Specification

last conformance run: 7/7 scenarios pass · 26 acts mapped 2026-09-15 02:40 UTC

Spec

Version0.5
Statuscurated draft — implementation not started
CuratorPeter Varga (single editor; spec PRs separate from implementation PRs)
Calibrationa curated target list worked by a small team, not a mass-market funnel
Provenanceextracted from a production system (the JC360 US market-entry CRM); what stayed behind and why is in §9

This document is the contract all implementations of the crm area share: business acts, invariants, lifecycles, and required read models — never screens or schemas. Anything two reasonable implementations might do differently is marked freedom. Scenario files under scenarios/ are the executable part.

---

1. Calibration: who this is for

An operator or small team pursuing tens of accounts they can name, where each account was researched into the list and relationships decide outcomes: market entries, agency work, enterprise sales with a defined territory, partner development. The unit of value is not volume — it is what you verifiably know about a small number of companies and people.

The reference points:

2. Scope

In scope (contract): accounts (curated, narrative-bearing) → contacts (named-with-source or gap) → activities → pipeline → promotion to a trading customer.

Deferred (§9): introductions-with-evidence, blocker/delay logs, ingestion (email/calendar), scoring, dedup/merge, multi-pipeline.

Rejected — permanent non-goals:

3. Entities and lifecycles

EntityLifecycle
campaignactive → paused (re-enterable) → concluded (terminal, reasoned). A campaign is a goal: the thesis every account's why_them argues against, and the standing brief an agent reads before filling the list
accounta pursuit within one campaign: not_started → researching → approaching → active → won — or parked on_hold (re-enterable), or terminal closed (pursued and lost/passed) / excluded (deliberately never pursued). Account names are unique per campaign
contactgap → named (via resolve_gap only) — a named contact may become departed; never deleted
activityimmutable once recorded (a fact, not a workflow object)
stage referencedata, not code: each pipeline stage carries a label and a probability (freedom to define the set)

won is the handoff moment: a won account may be promoted exactly once, creating (or linking) a core customer through the owning module's API. Pursuit ends where trading begins.

4. The acts

Acts are named abstractly; an implementation exposes each as a command under its own prefix and publishes an act → command map. Required arguments are contract. Refusals name what is missing, in one sentence, on every surface.

4.0 Campaigns

create_campaign (name, goal, target_profile?) The goal is required (CRM-13): a campaign without a stated thesis is a folder, not a pursuit. The goal is the brief — a research session reads it before adding accounts, and every why_them argues against it. Freedom: extra planning fields.

update_campaign (campaign, patch) — the goal can be sharpened, never blanked.

set_campaign_status (campaign, status, reason?) paused and concluded require a reason. Concluded is terminal: its accounts remain fully readable (CRM-6), but it takes no new targets.

4.1 Accounts

add_account (campaign, name, why_them, source_url, tier?, vertical?, trigger_event?, hook?) The gate to the list. campaign is required (CRM-12) and why_them must argue that campaign's goal; source_url grounds it — an account that cannot say why it belongs to this pursuit, with a source, is not a target yet (CRM-3). Refused into concluded campaigns. Freedom: tier semantics, vertical taxonomy, additional research fields.

update_account (account, patch) — corrects or deepens the narrative. Provenance fields may be corrected, never blanked (CRM-9).

set_account_status (account, status, reason?) Lifecycle moves, guarded per §3. on_hold, closed, and excluded require a reason — parking or killing a researched account is a decision someone later asks about (CRM-7).

set_path_in (account, bullets[]) — the ordered "how we get in" plan, replaced whole.

4.2 Contacts

add_contact (account, role_type, — then one of two shapes)

resolve_gap (contact, name, source, title?, confidence_note?) The only way a gap becomes named — and it demands the same provenance as a fresh named contact. Refused without a source.

update_contact (contact, patch) Corrections, departed marking, notes. The relationship-graph fields — mutual_via, mutual_url, linkedin_path — are human-only (CRM-4): an agent's write touching them is refused regardless of the member's role, with a sentence saying why.

4.3 Activity

log_activity (account, summary, occurred_at, contact?, direction?, medium?) occurred_at is when it happened, which is not when it was typed (CRM-5). Interpretation and summary are free prose; facts inside them follow CRM-10.

4.4 The bridge

promote_to_customer (account, terms?, credit_limit?) Allowed only for won accounts, at most once (CRM-8). Creates the core customer through core's exported API — a logged act marking where this area's job ends and o2c's begins — and links it on the account. Freedom: carrying contacts across.

4.5 Drafting, without a way to send

draft_message (contact, body, rationale, subject?, channel?) Writes a message to a named, live contact and holds it. Nothing is sent (CRM-14): this area has no outbound channel and the absence is deliberate, not a missing feature. A person reads the draft, edits it, sends it from their own mailbox and says so.

The rationale is mandatory and is never part of what goes out (CRM-16). It is the note to the reviewer: which hook the angle rests on, why this contact rather than another at the same account, what the writer was unsure about. A draft nobody can check is a draft nobody should send.

Refused for a gap row (there is no name to write to), for a departed contact, and for an account that is parked. Refused when the text contains a placeholder, or any phrase on the campaign's refused list (CRM-17).

update_draft (draft, body?, subject?, rationale?) Rewrites a draft that is still a draft. Refused on a sent draft, because its words are the record of what reached a real person (CRM-15), and refused on a discarded one, because a person rejected it and reviving it would overturn that decision quietly. The same claim gate applies: an edit cannot smuggle in what the first write would have stopped.

draft_outcome (draft, outcome, sent_at?, rejected_because?) Records what the person did. sent copies the draft's exact text onto the activity trail as the record of what actually reached somebody, makes the draft immutable, and moves an account still at not_started or researching to approaching — it has now been approached, which is a fact rather than a forecast. discarded needs a reason, because the next draft has nothing else to go on. Freedom: whether a send may be recorded by an agent on the person's word, or only by the person.

4.6 What a campaign may say

Claim lists live on the campaign and are human-only (CRM-17), for the same reason as CRM-4: an agent choosing which claims it is allowed to make is the check marking its own homework. claims_allowed is what this business has decided it may assert about itself; claims_refused is the phrases no draft may contain, checked at write time on creation and on edit alike.

The gate is honest about its limit: it catches a forbidden phrase, and it cannot catch an overstatement of a claim that is itself allowed. A campaign with no lists refuses nothing, and that is the correct default. A built-in list of somebody else's sensitivities would be a safety theatre, not a safety feature.

4.7 Parking

Parking is recorded through update_account and is not a status move (CRM-18). status is a judgement about where a pursuit sits; parking is a fact about what happened to it: both intake routes dead, the sponsor left, a reorganisation in flight. An account parked at researching is still at researching and says why nobody is spending time on it. The reason is mandatory and is read by whoever considers reopening; parking is reversible and drafting against a parked account is refused.

4.8 Things that will happen

add_event (account, title, date, source, time?, kind?, contact?, location?, url?, note?) The activity trail is what happened; an event is what will. A workshop the research agent found, a call somebody agreed to, a deadline a form published. The source is mandatory (CRM-20) because a date is a fact and the person deciding whether to turn up reads it back.

The rule the field exists for: a page listing dates in one column and topics in another has not given a date for any topic. Reading one off by position and writing to the person who owns the curriculum is how a first approach ends. If the source does not pin the date, there is no event.

update_event (event, status?, outcome?, reason?, date?, time?, source?, ...) Moving the date needs a reason and a source, as the date did the first time. done takes an outcome, because an event with no outcome is an intention that went stale. cancelled takes a reason. Neither is undone (CRM-21).

attend_event (event, attendance) Whether an event happens is the organiser's business; whether the person goes is theirs (CRM-22). Most events on a research-driven calendar were found, not scheduled, so reschedule and cancel are the wrong verbs for them and this is the right one: skip, attending, registered, or went. Recorded only on the person's word; an agent has no other source for it. went is refused before the date.

calendar (from?, to?, account?, status?) — read model: events by day in a window, and every event the person meant to attend that passed with no word since. The second list is the Monday one: found events pass unattended all the time and that is fine; a registered one passing in silence is not.

Act count

16 write acts + 7 read models — inside the 25-tool budget. Bulk arrival of unresearched rows is a different area with a different doctrine; see specs/prospect/spec.md, which promotes into this one through the owner's API the way this area promotes into core.

5. Invariants

Namespaced CRM-n (areas own their invariant namespaces; o2c's unprefixed INV-n is legacy).

  1. CRM-1 Never invent a person. A named contact requires a source and carries a confidence note. Facts — names, titles, dates, URLs — come only from sources. Empty beats guessed; guessing is the only real failure here.
  2. CRM-2 A gap is a finding. A role with no publicly named holder is recorded as a gap row with a gap note, appears in the gap worklist, and becomes named only through resolve_gap with full provenance.
  3. CRM-3 Every account earned its place. No account without why_them and a source_url. This is a curated list, not a funnel.
  4. CRM-4 Some fields are human-only. Relationship-graph fields are written by people, never by agents — whatever the member's role — because automating them risks the human relationships (and accounts, in both senses) this work depends on. The refusal says so in one sentence.
  5. CRM-5 Activities record when it happened, not when it was typed.
  6. CRM-6 Nothing is deleted. Exclusion and closure are reasoned statuses; departed contacts and their history remain.
  7. CRM-7 Parking or killing requires a reason. on_hold, closed, excluded carry prose that will be read back.
  8. CRM-8 Promotion is a bridge act. won → core customer, through the owner's API, logged, at most once per account.
  9. CRM-9 Provenance survives updates. Sources and confidence may be corrected, never removed.
  10. CRM-10 Interpretation is free, facts are sourced. Narrative fields may summarize and judge; factual claims inside them trace to sources or are marked as unknown.
  11. CRM-12 Every account pursues a campaign. Pursuit state — why_them, tier, status, path-in — is campaign-relative; the same company may be a target of two campaigns as two accounts. (The org/pursuit normalization this implies is deferred: §9.)
  12. CRM-13 No campaign without a goal. The goal is data, not a chat prompt: it is the brief an agent reads before filling the list, and the thesis every why_them argues.
  13. CRM-14 There is no outbound channel. A draft is written and held. Only a person sends, from their own mailbox, and then records that it went. No implementation of this area may send a message, and an implementation that does is not this area.
  14. CRM-15 A sent draft is immutable. Its exact words are copied onto the activity trail as the record of what reached a real person, and that record is not rewritten. A discarded draft is never revived: a person rejected it, and reversing that silently is worse than writing a new one.
  15. CRM-16 Every draft carries a rationale for its reviewer, and the rationale is never part of what is sent.
  16. CRM-17 Claims are a person's to make. What a campaign may assert about itself, and what no draft may say, are written by people and refused to agents whatever their role. The refused list is checked on creation and on edit alike.
  17. CRM-18 Parking is orthogonal to status. It is a fact about what happened, carries a mandatory reason, is reversible, and does not move the pursuit.
  18. CRM-19 State is derived, never stored. Last touch, who has been written to, and what is waiting are computed on read and come back with the account. A stored summary drifts, and the drift has a shape: an account reported as an untouched door days after somebody wrote to it.
  19. CRM-20 A date is a fact. An event carries the source its date came from: a URL, or who said it and when. A date the source does not pin is not a date and is not recorded.
  20. CRM-21 Events close, they do not vanish. Done takes an outcome, cancelled takes a reason, and neither is reopened; a cancelled thing that happens after all is a new event.
  21. CRM-22 Attendance is the person's. Whether an event happens is the organiser's fact; whether the person goes is a separate axis, recorded only on their word, and never inferred from the date having passed.
  1. CRM-11 Platform inheritance. Every write is a logged command with an actor; refusals (including CRM-4 and CRM-17 denials) are logged; reads are never logged.

6. Required read models

Read modelMust answer
accountfull narrative (why/trigger/hook/sources), path-in bullets, contacts including gaps, recent activity, available next actions with refusal reasons
contactidentity, provenance (source + confidence), gap state, activity touching them
pipelineaccounts by status and tier with stage probabilities — the weighted "where are we" view
gapsevery unresolved gap: role, account, gap note, age — the "what we verifiably don't know" worklist, first-class
coveragelist health: accounts by status/tier, gap counts, accounts with no activity in N days — staleness is visible, not discovered
campaignsevery campaign with its goal, status, and health (accounts by status, open gaps, staleness) — the per-goal Today

pipeline, gaps, coverage and the account list must answer per-campaign and across campaigns (an optional campaign filter is contract; its shape is freedom).

7. Contract vs freedom — summary

ContractFreedom
the 9 acts, required args, refusal semanticsextension commands, extra optional args
lifecycles in §3, all 11 invariantsschema, id formats, tier/vertical taxonomies
read model content (§6)shapes, pagination, extra views
provenance mandatory; gaps first-classwhich fields beyond the graph set are human-only
stage probabilities from reference datathe stage set itself
promotion via core's API, oncecarrying contacts/notes across on promotion
buyer/persona doctrine— it lives in workspace content (seeded guidance, account notes), not module code

8. Conformance

As for o2c: an act → command map covering §4, every scenario under scenarios/ passing (steps name acts; refusals are contract), and the mechanically checkable invariants asserted by the platform gates. One note specific to this area: the conformance runner executes as an agent, so CRM-4 scenarios assert the refusal side; the human-path acceptance is asserted by a gate test, not a scenario.

9. Deferred — with reasons (the extraction record)

This spec was extracted from a production single-tenant CRM built for a specific agency agreement. These stayed behind:

ItemWhy deferred, not lost
Introductions with mandatory evidenceThe source system's introduction table made commission conditional on documented evidence — NOT NULL constraints that were literally sentences of §V of an agency agreement. Generalizable someday as "milestone with mandatory evidence," but shipping contract law as a default CRM concept helps nobody.
Blocker / delay logEncoded §3 of the same agreement (principal-attributable delays extending the term). A generic "blocked, on whom, since when" may return; the contractual accrual math stays bespoke.
Agreement fields (agreed_with_principal_at, tier-1-only hooks)Deployment policy, not area semantics — in Saybooks terms, workspace content.
Org / pursuit normalizationThe fully normalized model splits company (org facts, people, the human-entered network) from pursuit (campaign membership), so one company in two campaigns shares its contacts. Deferred until a company actually appears in two campaigns: today the cost of overlap is a duplicated account row with duplicated contacts — annoying, visible, fixable then. This line exists so the fault line is on record as seen, not missed.
Email/calendar ingestionIntegration territory; the area records, it does not watch.
Lead scoring, dedup/merge, multi-pipelineFunnel-CRM machinery; against this area's calibration until real demand says otherwise.

---

Change log: 0.2-draft amended (2026-08-24) — contacts gain optional email and phone on add_contact / resolve_gap / update_contact: in many industries reach details are plainly listed, and recording them is a sourced fact like a LinkedIn URL (CRM-1 covers the where-from; nothing changes about CRM-4 — the relationship-graph fields stay human-only). · 0.2-draft (2026-08-23) — campaigns become first-class (CRM-12, CRM-13): the 0.1 extraction had erased the campaign by promoting it to "the whole deployment"; a second research fill made it visible again. Acts +3, read models +1, org/pursuit split explicitly deferred. · 0.1-draft (2026-08-22) — initial curation, extracted from the JC360 US CRM. New platform mechanism motivated by this area: field-level human-only enforcement (CRM-4).

Acts

ActKindRequired
add_accountwritecampaign name why_them source_url
update_accountwriteaccount
set_account_statuswriteaccount status
set_path_inwriteaccount bullets
add_contactwriteaccount role_type
resolve_gapwritecontact name source
update_contactwritecontact
log_activitywriteaccount summary occurred_at
promote_to_customerwriteaccount
accountreadaccount
contactreadcontact
pipelinereadnone
gapsreadnone
coveragereadnone
create_campaignwritename goal
update_campaignwritecampaign
set_campaign_statuswritecampaign status
campaignsreadnone
draft_messagewritecontact body rationale
update_draftwritedraft
draft_outcomewritedraft outcome
draftsreadnone
add_eventwriteaccount title date source
update_eventwriteevent
calendarreadnone
attend_eventwriteevent attendance

Invariants

IdInvariant
CRM-1Never invent a person: named contacts require a source; facts come only from sources.
CRM-2A gap is a finding: recorded with a note, listed in the worklist, resolved only with provenance.
CRM-3Every account earned its place: why_them and source_url are mandatory.
CRM-4Relationship-graph fields are human-only: refused to agents regardless of role.
CRM-5Activities record when it happened, not when it was typed.
CRM-6Nothing is deleted: exclusion and closure are reasoned statuses.
CRM-7Parking or killing an account requires a reason.
CRM-8Promotion is a bridge act: won → core customer via the owner's API, at most once.
CRM-9Provenance survives updates: correctable, never removable.
CRM-10Interpretation is free; facts are sourced.
CRM-11Every write is a logged command with an actor; refusals are logged; reads are not.
CRM-12Every account pursues a campaign; pursuit state is campaign-relative.
CRM-13No campaign without a goal — the goal is the brief agents read before filling the list.
CRM-14There is no outbound channel: a draft is written and held; only a person sends it.
CRM-15A sent draft is immutable and its exact words are copied onto the activity trail; a discarded draft is never revived.
CRM-16Every draft carries a rationale for its reviewer, and the rationale is never part of what is sent.
CRM-17What a campaign may claim is written by a person; refused words are checked against every draft and edit alike.
CRM-18Parking is a fact about what happened and is orthogonal to status; it needs a reason and is reversible.
CRM-19Account state is derived on read, never stored: an approach already made is never reported as an untouched door.
CRM-20An event carries the source its date came from; a date not on the source is not a date.
CRM-21Events are never deleted or reopened: done takes an outcome, cancelled takes a reason.
CRM-22Attendance is the person's stance and a separate axis from the event's status; it is recorded only on their word.

Scenarios

Each scenario is a file of acts with expected outcomes. ok means the act must succeed with the listed fields; refused means the act must be refused with a sentence containing the listed text. Refusals are contract.

no invented people: provenance or gap, nothing in between pass

CRM-1 (never invent a person), CRM-2 (a gap is a finding), CRM-3 (every account earned its place). The area's character in one flow: what you know arrives with sources; what you don't know is recorded as exactly that.
#ActExpectWhy
1create_campaignok
2add_accountrefused why_themCRM-3: an account that cannot say why it belongs, with a source, is not a target yet
3add_accountok status="not_started"
4add_contactrefused sourceCRM-1: a name without a source is not usable in a first approach — refused, not warned
5add_contactok status="named"
6add_contactok status="gap"CRM-2: the absence of a name is a finding worth recording, not a blank to fill later
7gapsok length=1the gap worklist is first-class: 'what we verifiably don't know' is a view, not a feeling
8resolve_gaprefused sourceCRM-2: resolving a gap demands the same provenance as a fresh contact — the gap does not lower the bar
9resolve_gapok status="named"
10gapsok length=0

the fields no agent may touch pass

CRM-4: relationship-graph fields (mutual_via, mutual_url, linkedin_path) are written by people only, whatever the role. The conformance runner executes as an agent, so this scenario proves the refusal side; human acceptance is a gate test. CRM-11: the denial is logged.
#ActExpectWhy
1create_campaignok
2add_accountok
3add_contactok
4update_contactokordinary fields are fine — interpretation is the agent's job (CRM-10)
5update_contactrefused humanCRM-4: whoever's delegate the agent is, the relationship graph is entered by a person — the refusal explains why, in one sentence
6update_contactrefused human

pursuit ends where trading begins pass

CRM-6/7 (reasoned statuses, nothing deleted), CRM-5 (occurred_at), CRM-8 (promotion: won only, once, via core's API). The bridge between areas, as a logged act.
#ActExpectWhy
1create_campaignok
2add_accountok
3set_account_statusok
4log_activityokCRM-5: it happened on the 10th; today is when it got typed — the record keeps the 10th
5set_account_statusrefused reasonCRM-7: killing a researched account without saying why is refused — the list's value is that every change to it can be read back
6promote_to_customerrefused wonCRM-8: promotion is for won accounts — pursuit and trade have a boundary, and this is it
7set_account_statusok
8set_account_statusok
9set_account_statusok
10promote_to_customerokcreates the core customer through core's exported API — one logged act marking where crm's job ends and o2c's begins
11accountok status="won"
12promote_to_customerrefused Already promotedCRM-8: at most once — the link exists; a second customer would be a reconciliation problem wearing a convenience

a campaign is a goal, not a folder pass

CRM-12 (every account pursues a campaign; pursuit state is campaign-relative), CRM-13 (no campaign without a goal), CRM-7 extended (pausing/concluding reasoned), CRM-6 (concluded keeps everything).
#ActExpectWhy
1create_campaignrefused goalCRM-13: a campaign without a stated thesis is a folder. The goal is the brief an agent reads before adding anything
2create_campaignok status="active"
3create_campaignok
4add_accountok campaign_id="CAM-0001"
5add_accountokCRM-12: the same company under two campaigns is two pursuits with two whys — pursuit state is campaign-relative
6add_accountrefused already onwithin one campaign, one row per company — a twin is a data bug, not a second chance
7pipelineok length=1
8pipelineok length=2read models answer per-campaign AND across — both are contract
9set_campaign_statusrefused reasonCRM-7 extended: a goal someone stops pursuing is a decision that gets asked about later
10set_campaign_statusok status="concluded"
11add_accountrefused concludedconcluded takes no new targets — but everything it holds stays readable (CRM-6)
12accountok campaign_id="CAM-0002"

the agent writes, a person sends pass

CRM-14 nothing here sends, CRM-16 the rationale is mandatory and never goes out, CRM-15 a sent draft is the record and does not get rewritten, CRM-17 claims are a person's to write, CRM-18 parking is not a status, CRM-19 state is derived.
#ActExpectWhy
1create_campaignok
2add_accountok status="not_started"
3add_contactok status="gap"CRM-2: the role has no publicly named holder yet, which is a finding.
4draft_messagerefused gapCRM-1 and CRM-14 together: there is no name to write to, so there is nothing to draft.
5resolve_gapok status="named"
6draft_messagerefused placeholderAn unfinished draft is caught here rather than by a person reading it too fast at 7am.
7draft_messageok status="draft"CRM-16: the rationale is what makes this checkable, and it is not part of what would be sent.
8accountok status="not_started"CRM-14: a written draft has approached nobody. The account has not moved.
9update_draftok status="draft"The angle changed, so the draft is rewritten rather than discarded: discarding would lose what was already tried on this contact.
10draft_outcomerefused sent_atCRM-5: when it actually went, not when this was typed.
11draft_outcomeok status="sent" account_status="approaching"CRM-15: the words are copied onto the trail. The account moves to approaching because it has been approached, which is a fact and not a forecast.
12update_draftrefused does not get rewrittenCRM-15: rewriting it would leave these books describing a message nobody sent.
13draft_outcomerefused closed onceA draft has one ending.
14accountok status="approaching"CRM-19: the approach comes back with the account, so nobody reports this as an untouched door on Monday.
15update_campaignrefused human-onlyCRM-17: an agent choosing which claims it may make is the check marking its own homework.
16update_accountok status="approaching" parked=trueCRM-18: parking is a fact about what happened. The pursuit is still at approaching, and now says why nobody is spending time on it.
17draft_messagerefused parkedParking means what it says: no more time goes here until somebody unparks it.
18update_accountok parked=falseCRM-18: reversible, and the pursuit never moved.
19draftsok total_matching=1CRM-14: 'sent' here means a person told us they sent it. Nothing in this system can send anything.

a date is a fact pass

CRM-20 the source is mandatory and the date comes from it, CRM-21 events close with an outcome or a reason and never reopen.
#ActExpectWhy
1create_campaignok
2add_accountok
3add_eventrefused sourceCRM-20: 'found' is not a source. Where is the date?
4add_eventok status="planned"
5add_eventrefused timeA time is a fact too, and a malformed one is refused rather than stored.
6calendarok count=1
7update_eventrefused reasonMoving the date needs a reason and a source, as the date did the first time.
8update_eventok date="2026-10-05" status="planned"
9update_eventrefused outcomeCRM-21: an event with no outcome is an intention that went stale.
10update_eventok status="done"
11update_eventrefused closedCRM-21: done stays done. If it is happening again, that is a new event.

found, not scheduled: the verb is attendance pass

CRM-22 attendance is a separate axis from the event's status; went is refused before the date; a cancelled event cannot be attended.
#ActExpectWhy
1create_campaignok
2add_accountok
3add_eventok
4attend_eventrefused has not happened yetCRM-22: went is for afterwards.
5attend_eventok attendance="registered"
6calendarok count=1Still planned: registering changed nothing about the event itself.
7update_eventok status="cancelled"
8attend_eventrefused nothing to attendThe organiser's fact wins: a cancelled event has no attendance.

These pages are generated from the files in specs/ in the repository; scenarios are executed by src/conformance.js on every build. Written and maintained by Peter Varga, who also runs Portland AI Works. github.com/alviso/saybooks, AGPL-3.0.