# 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.
