@odla-ai/crm
v0.9.0
Published
A flexible CRM layer for odla apps — configurable record types (people, companies, anything), pipelines with gated transitions, tags, notes and follow-ups, email consent + audited sends, and saved views, all on odla-db with a mountable route factory and a
Downloads
2,188
Maintainers
Readme
@odla-ai/crm
A flexible CRM layer for odla apps — configurable record types (people, companies, anything), pipelines with gated transitions, tags, notes and follow-ups, consent-gated email with audited sends, and saved views, all on odla-db with a mountable route factory and an admin UI kit over @odla-ai/ui.
npm i @odla-ai/crmAgentic experiment. This package is built and maintained by AI agents working from bounded runbooks with human review. Review its documented guarantees before relying on it.
Ask the runbooks first. odla's operational procedures live in a database, not in this file:
npx @odla-ai/cli runbook ask "<question>"returns the current steps, and unlike anything written here it cannot be out of date. Use it before searching the web or working from memory. This README and the JSDoc in the shipped.d.tsare the version-matched API reference; a runbook is the procedure. Most tasks need an answer from both.
The shape
The CRM is a schema + deny-all rules + a route factory + an admin UI kit — not a service you run.
- Record types are configuration, not schema. Every type shares one generic
crm_recordnamespace: promoted columns for the hot paths every type has (name, stage + position, rank, score, contactability, billing cache), nine typed slot columns (s1–s4,n1–n3,d1–d2) that index each type's custom sortable/filterable fields, the complete field payload in a json column, and a denormalized FTS haystack rebuilt on every write. - Tags and relations are natural-key join rows (
recordId:tag,fromId:relation:toId) — idempotent upserts for human-curated cohorts and graph edges. Machine federation identity and delivery state use separate structured origin/delivery rows, so editing a display tag cannot break a retry contract. - Consent is a table-driven state machine. Suppression is admin-sticky, an unsubscribe is undone only by the contact's own opt-in, hard bounces land from anywhere, and
unsubscribedstill receives transactional mail. Marketing sends carry{{unsubscribeUrl}}and RFC 8058 one-click headers automatically. - All access is worker-mediated by design. Slot mapping, searchText rebuilds, pipeline gates, and validation are functions of your config — CEL rules cannot express them — so every
crm_*namespace installs deny-all andcreateCrmRoutes(admin key, behind your ownauthorize) is the only door. - Zero runtime dependencies. The db client and email transport are injected structurally: a real
@odla-ai/dbAdminDband a thin wrapper over@odla-ai/email'ssendMessagesatisfy the interfaces.
Quick start
import { defineCrm, createCrmRoutes } from "@odla-ai/crm";
import { init } from "@odla-ai/db";
export const crm = defineCrm({
types: {
person: {
label: "Person",
emailField: "email",
fields: {
name: { type: "string", required: true },
email: { type: "email" },
state: { type: "enum", options: ["UT", "CA"], slot: "s1" },
netWorth: { type: "number", slot: "n1" },
},
pipeline: {
stages: [{ id: "prospect" }, { id: "applied" }, { id: "member", terminal: true }],
transitions: { prospect: ["applied"], applied: ["member"] },
gates: { member: (r) => (r.emailStatus === "none" ? "needs an email first" : null) },
},
facets: { identity: true, email: true, rank: "manual" },
onTransition: { member: async (r) => promoteToMember(r) }, // post-commit hook
},
company: { label: "Company", fields: { name: { type: "string", required: true } } },
},
relations: { works_at: { from: "person", to: "company", label: "works at" } },
templates: {
welcome: { vars: ["firstName"], class: "transactional",
defaults: { subject: "Welcome {{firstName}}", text: "You're in." } },
newsletter: { vars: ["firstName", "unsubscribeUrl"], class: "marketing" },
},
});
const db = init({ appId, adminToken, endpoint }); // @odla-ai/db admin client
const crmRoutes = createCrmRoutes({
crm,
db,
authorize: async (req) => ((await isAdmin(req)) ? { userId: await adminId(req) } : null),
// Registry-owned read assertion; it cannot authorize a CRM mutation.
authorizeDiscussionReferences: async (req) => {
const actor = await verifyRegistryCrmReference(req);
return actor ? { userId: actor.id, discussionReferenceScope: "all" } : null;
},
envName: env.ENV_NAME, // only exactly "production" delivers real email
baseUrl: "https://app.example.com",
from: env.EMAIL_FROM,
sender: { send: (payload) => sendMessage(mySender, payload) }, // optional
});
export default {
async fetch(req: Request): Promise<Response> {
return (await crmRoutes(req)) ?? myAppRoutes(req); // null = not a crm path
},
};Compose reusable and host-owned CRM modules before resolving them:
import { composeCrmConfigs, defineCrm } from "@odla-ai/crm";
const crm = defineCrm(composeCrmConfigs(peopleConfig, portfolioConfig));Duplicate type, relation, or template ids fail instead of silently replacing
one module. A type may set workspace: "portfolio" (or another host-defined
string) as a generic UI grouping hint; core CRM CRUD and authorization do not
interpret that value.
GET /api/crm/discussion-references?q=… searches product-owned records and
activities; exact kind + id lookup proves the current row and parentage.
Results contain canonical kind/id/label, bounded summary/status/destination,
and a same-origin odla-ref link that opens the record or focused activity.
For a Registry caller, authorizeDiscussionReferences must verify the exact
crm product, crm.read, canonical audience, app incarnation, environment,
principal, and—when present—manager plus grant revision. Ordinary agent
handshakes exclude CRM; an owner must explicitly opt into read-only
crm.read. This scope cannot edit records, send mail, or approve anything.
Put the resolved CRM in an app module both the Worker and checked-in odla config can import, then declare the project-specific integration:
// odla.config.mjs
import { createCrmIntegration } from "@odla-ai/crm";
import { crm } from "./src/crm.js";
export default {
app: { id: "my-app", name: "My App" },
services: ["db"],
integrations: [createCrmIntegration(crm, {
basePath: "/api/crm",
notificationEmail: "[email protected]",
})],
links: { dev: "https://dev.example.com" },
};odla-ai provision collision-checks and merges the CRM schema/rules, then
creates crm_config only when absent. odla-ai doctor checks the composed
namespaces/rules/seed contract offline; odla-ai smoke uses links.<env> to
verify the mounted records route returns 401 without credentials. Mounting
createCrmRoutes and supplying authorize remain app-owned source work.
And the admin surface, from @odla-ai/crm/ui (native Preact,
--ui-* app-tier styling). Load the CRM sheet after the selected UI theme:
import "@odla-ai/ui/themes/paper/tokens.css";
import "@odla-ai/ui/themes/paper/ui.css";
import "@odla-ai/ui/themes/paper/scope.css";
import "@odla-ai/ui/index.css";
import "@odla-ai/crm/ui.css";
import { CrmClient } from "@odla-ai/crm";
import { CrmWorkspace } from "@odla-ai/crm/ui";
const client = new CrmClient({ headers: async () => ({ authorization: `Bearer ${await token()}` }) });
function People() {
return (
<CrmWorkspace
crm={crm}
client={client}
type="person"
recordId={route.recordId}
detailTab={route.detailTab}
onRecordIdChange={setRecordId}
onDetailTabChange={setDetailTab}
/>
);
}CrmWorkspace is the standard responsive list/detail composition.
RecordPanel remains available independently and accepts an extensible,
controlled tab registry for application-specific operations.
Preserve a branded CRM workspace
CrmWorkspace owns loading, search, pagination, selection, responsive pane
switching, and record refresh. Render slots let the host retain its information
design:
<CrmWorkspace
crm={crm}
client={client}
type="person"
recordId={route.recordId}
detailTab={route.detailTab}
hrefForRecord={(record) => `#people/person/${record.id}/profile`}
hrefForList="#people/person"
hrefForDetailTab={(tab) => `#people/person/${route.recordId}/${tab}`}
renderSummary={({ query }) => <Summary total={query.page?.total ?? 0} />}
renderMaster={({ defaultMaster }) => <BrandedRail>{defaultMaster}</BrandedRail>}
renderDetailHeader={({ detail }) => <RecordHeading record={detail.record} />}
renderDetail={(context, defaultDetail) => (
<RelationshipProfile record={context.detail.record}>
{defaultDetail}
</RelationshipProfile>
)}
/>Record links, the mobile back control, and detail tabs become native anchors
when their hrefFor* builders are supplied. Record panels mount only the
active tab by default, preventing hidden communications or scheduling panels
from fetching data. renderDetail receives the fully composed default record
panel, so a host can wrap or replace the entire detail experience while CRM
continues to own loading, selection, mutation state, and tab composition.
For records whose displayed pipeline mirrors another authoritative workflow, delegate transitions instead of calling the CRM route directly:
<CrmWorkspace
{...props}
requireLifecycleAdapter
lifecycle={{
transitionStage: ({ record, from, to }) =>
operations.transition({ record, from, to }),
}}
/>Without requireLifecycleAdapter, generic CRM collections retain the
client.setStage default. With it, a missing adapter fails closed. Custom tabs
may declare visible(detail) and disabled(detail) predicates; invisible
panels are neither rendered nor mounted.
What ships where
| Concern | Where it lives |
| --- | --- |
| Types, pipelines, gates, scorers, template contracts | Code — defineCrm (validated at boot) |
| Template copy, enable flags, destination addresses | Data — the crm_config row (owner-editable) |
| Records, tags, links, activities, channels, send audit, views | odla-db (CRM_SCHEMA namespaces) |
| Cross-system record identity and latest target delivery state | crm_record_origin and crm_record_delivery |
| Money | Nowhere here — BillingProvider is an optional port an app-owned client or reusable provider package may implement. The record carries cache columns only. |
| Meetings/scheduling | @odla-ai/calendar |
| Clerk role changes | Your onTransition hooks (via @odla-ai/auth-clerk) |
Guarantees & limits
- Every write accepts a
mutationIdfor exactly-once application; webhook-style callers key on it ({provider}:{eventId},consent:{token}:{event},email:{dedupeKey}). upsertRecordOriginbinds one source site + source record id to one local record and refuses remapping.recordDeliveryAttemptretains the latest per-record/per-target status and bounded error while incrementing attempts.GET /api/crm/records/:idincludesoriginsanddeliveries; the standard record UI exposes them in a Network tab only when data exists.- Discussion reference projections are read-only and product-authorized; a
stored ref or
odla-reflink is navigation, never CRM mutation authority. - Send-path failure rows are written unconditionally, so a retry's success is never swallowed; dev environments redirect to
debugEmailor fall back to log-only. - Sorting is server-side on exactly one indexed field (odla-db's contract): promoted columns and slotted fields only — the UI kit marks exactly those sortable.
- List windows cap at 200 rows; tag cohorts resolve up to 1000 members; unsubscribe GETs never mutate (mail scanners prefetch).
MIT © odla
