Concepts

UndoCRM is built for the agent doing the work, not for a manager reading reports. Everything here exists to make an agent's next action correct, with as few calls and tokens as possible.

UndoCRM runs no LLM. Your agent reads the conversation and decides what matters; UndoCRM stores it, enforces rules, and coordinates agents. That keeps it fast, cheap, deterministic, and private — nothing you store is sent to a model by us.

Organizations, workspaces, people and keys

An organization is an UndoCRM customer, typically a company that builds agents. It has people (who sign in) and owns many workspaces. A workspace is fully isolated — its own contacts, agents, keys and audit log — and usually stands for one end customer the agents serve.

People sign in with email (a magic link or a password). Their role is set per organization:

Role Can
Owner Everything, including managing other owners. An organization always has at least one.
Admin Members and invites, workspaces, agents and API keys, lifting do-not-contact
Member Supervise: timelines, what agents see, deals, tasks, undo, blocking contacts

Invites are sent by email or shared as a link, and only work for the invited email address.

Programs use API keys:

Key Prefix Scope Used for
Org ucrm_org_ An organization Your backend creating and listing workspaces (POST /v1/workspaces)
Owner ucrm_own_ One workspace Your backend administering that workspace: agents, keys, audit, undo
Agent ucrm_agt_ One workspace, one agent MCP and the agent endpoints; every write is attributed to the agent

Keys are stored as SHA-256 hashes. A revoked key stops working within 30 seconds. Every change — by an agent, a key or a person — is in the audit log with who made it; organization-level changes (members, invites, workspaces, keys) are in Settings → Organization.

People and identities

A contact is a person. An identity is one way to reach them: an email, a phone number, or a handle on a platform (linkedin:janedoe, x:jane, github:jdoe, …). Each identity belongs to exactly one contact in a workspace.

Every tool takes a person string, which can be any of:

You have Pass
Contact id ct_3f9a0c2b7e1d4a58
Email jane@acme.com (case-insensitive)
Phone +14155550100 (any formatting; E.164 matches most reliably)
Handle linkedin:janedoe, x:@jane, telegram:jane
Profile URL https://linkedin.com/in/janedoe

Merging. When an agent tells UndoCRM that two identities belong together (save_contact with both), and they currently sit on different records, the records are merged into the oldest one. Interactions, facts, follow-ups, blocks and claims all move. Old contact ids keep working. A human can undo the merge from the audit log; everything moves back.

Companies are created from a name or domain. Work emails link a company automatically (jane@acme.com → Acme, acme.com); personal domains like gmail.com don't.

Properties

Contacts, companies and deals have properties, named as in HubSpot: firstname, lastname, email, phone, jobtitle, company, lifecyclestage on contacts; dealname, dealstage, amount, closedate on deals. Agents that know HubSpot already know these.

Anything else an agent sets (budget, timeline, current_tool) is a custom property. Custom properties are stored as append-only facts (below) and listed in a per-workspace property registry, so every agent can see which names exist (read(target: "properties")). When an agent introduces a name that looks like an existing one — annual_budget next to budget — the response points it out, and the workspace converges on one vocabulary instead of drifting.

Deals and pipelines

A deal sits in one stage of one pipeline, as in HubSpot. Every workspace starts with HubSpot's default Sales Pipeline (appointmentscheduled → qualifiedtobuy → presentationscheduled → decisionmakerboughtin → contractsent → closedwon / closedlost). Create others for anything with stages — recruiting (Applied → Screen → Onsite → Offer → Hired / Rejected), onboarding, support escalations. Agents pass a stage key or its label; humans drag deals between stages on the board.

Interactions

An interaction is one touchpoint: a message sent, a reply received, a call, a meeting, or an internal note. It has a channel, a direction (outbound, inbound, internal), a short summary written for future agents, and optionally the raw content (stored, never put in briefs).

Every interaction is searchable: read(target: "interactions", query: "SOC2") runs full-text search across all conversations in the workspace. Interactions can carry a thread_id (an email thread) and a deal_id.

external_id makes logging idempotent: log the same email Message-ID twice and the second call is a no-op. Interactions also carry a source (agent by default), which is how email and calendar sync will plug in later: a synced Gmail message is just an interaction with source = gmail.

Facts

A fact is something learned about a person: budget = $50k/yr, decision_maker = CTO, timezone = CET. Keys are normalized to snake_case.

Facts are never overwritten. Saving a new value for a key adds a row; the newest is current, and the brief shows what it replaced (budget: $80k (was: $50k)). Each fact records which agent wrote it, when, and from which interaction. Saving the same value again is a no-op. Undo retracts a fact; it's still in the database.

Tasks

A task (a follow-up) is something an agent owes: a subject, a due time, optionally a channel and a deal. Agents create them inside the interaction they came from (tasks: [...]), find them with read(target: "tasks"), and close them with update(fu_…, {status: "done"}) — or by passing their ids in completes_tasks when logging the interaction that fulfilled them.

Rules: do-not-contact and claims

can_contact(person, channel) is the gate before every outbound message. It returns ALLOWED or BLOCKED, and the reason.

Do-not-contact blocks one channel or all of them. Agents add blocks with update(person, {do_not_contact: "email" | "all"}) the moment someone opts out. Agents cannot lift blocks — only a human can, in the web app or with an owner key. Agents may tighten rules; only people may loosen them.

Claims stop two agents messaging the same person. When can_contact says ALLOWED, the person is claimed for the calling agent (24 hours by default, up to 30 days). Other agents get BLOCKED — claimed by sdr-1 until … with the claimer's stated purpose. Calling again extends your own claim. can_contact(person, release: true) hands the person back. The check-and-claim runs under a row lock, so two agents asking at the same instant can't both be allowed.

The brief

read(person, purpose, max_tokens) returns the brief: the one read an agent needs before acting. It's built without an LLM, in a fixed priority order:

  1. Header — name, role, company, id, identities. Always included.
  2. Rules — do-not-contact, who holds a claim, last outbound. Always included, whatever the budget.
  3. Open tasks — overdue first.
  4. Deals — open ones first, with stage, amount and close date.
  5. Facts — current values, ranked by word overlap with purpose, then recency; then facts about their company.
  6. Recent interactions — newest first, with purpose-relevant ones pulled forward.

Sections fill until max_tokens (default 500) runs out; the brief ends by saying what it left out. A typical brief is 150–300 tokens.

Audit and undo

Every write — by an agent or a human — is in the audit log: who, what, when, and enough data to reverse it. Humans see it in the web app's Activity page and on each contact's timeline.

Action Undo does
Contact created Hides the contact and frees its identities
Contact updated Restores the previous name, title, company
Identities added Removes them
Contacts merged Splits them back apart, moving everything back
Interaction logged Hides it, retracts its facts, cancels its follow-ups, reopens follow-ups it completed
Facts recorded Retracts them
Fact retracted (by delete) Restores it
Deal created / updated / archived Archives it / restores the previous stage, amount and dates / restores it
Contact archived Restores it with its identities
Interaction deleted Restores it, its facts and its tasks
Follow-up closed Reopens it
Do-not-contact set / lifted Lifts / restores it
Claim created Releases it

Undo never deletes. It's recorded in the log too.