# Quickstart UndoCRM is the CRM for AI agents: where your agents remember who everyone is, what was said, and what's owed. This page takes you from sign-up to an agent using it in a few minutes. ## 1. Sign up Create an account at [https://undocrm.com/app/signup](https://undocrm.com/app/signup) with your email. You'll name your organization — usually your company — and UndoCRM creates its first workspace for you. ## 2. Create a workspace A workspace holds one set of contacts, agents and history, fully isolated from the others. If your agents work for one company, the first workspace is all you need. If you build agents for many customers, give each customer its own workspace: create them in **Settings → Workspaces**, or from your backend (see [Serve many customers](#6-serve-many-customers)). ## 3. Create an agent key In the web app, open **Agents → New agent**, name it (for example `claude-code` or `sdr-1`), and copy its key. Keys start with `ucrm_agt_` and are shown once. Each agent gets its own key, so everything it writes is attributed to it. ## 4. Connect your agent In Claude Code: ```bash claude mcp add --transport http undocrm https://undocrm.com/mcp \ --header "Authorization: Bearer " ``` Any MCP client that speaks Streamable HTTP works the same way: point it at `https://undocrm.com/mcp` and send the agent key as a bearer token. Prefer REST? The same operations are at `https://undocrm.com/v1` — see the [HTTP API](/docs/http-api). ## 5. Try it Ask your agent something like: > Jane Doe (jane@acme.com) just replied: their budget is $50k, they need a SOC2 report, and she asked us to follow up next Tuesday. Log it. The agent calls `create(type: "interaction")` once: the interaction, two facts and a task, and Jane's contact record if she's new. Then: > What do we know about jane@acme.com before I email her? That's `read` — the brief. Then `can_contact` before it sends anything. Try *"Jane signed — mark the Acme deal won"* and watch the deal move on the board. Open [https://undocrm.com/app](https://undocrm.com/app) to see the home dashboard, Jane's timeline, the deals board, and every agent write in the Activity log — with an Undo on each. ## 6. Serve many customers Building agents for many companies? Create an **org API key** in **Settings → API keys**, then create a workspace per customer from your backend: ```bash curl -X POST https://undocrm.com/v1/workspaces \ -H "Authorization: Bearer $ORG_KEY" -H "content-type: application/json" \ -d '{"name": "Acme Corp"}' # → { "workspace": {...}, "owner_key": "ucrm_own_..." } ``` Use the workspace's owner key to create its agents: ```bash curl -X POST https://undocrm.com/v1/agents \ -H "Authorization: Bearer $OWNER_KEY" -H "content-type: application/json" \ -d '{"name": "recruiter-bot", "description": "Screens inbound candidates"}' ``` How many workspaces you can create depends on your [plan](/#pricing). ## 7. Invite your team **Settings → Members → Invite.** Owners and admins manage members, workspaces and keys; members supervise: timelines, deals, tasks and undo. ## The loop every agent should follow | Step | Tool | Why | | --- | --- | --- | | Before acting | `read(person)` | Know the rules, deals, history, and what's owed | | Before any outbound message | `can_contact` | Respect opt-outs; claim the person so no one else messages them | | After | `create(type: "interaction")` | Record what happened, what you learned, what you owe | | When someone opts out | `update(person, {do_not_contact})` | Immediately, every channel they named | | When a deal moves | `update(dl_…, {dealstage})` | Keep the pipeline true | The MCP server sends these instructions to the agent when it connects, so most agents follow the loop without being told. Next: [Concepts](/docs/concepts) explains the model. [MCP tools](/docs/mcp-tools) is the full reference. --- # 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. --- # MCP tools Endpoint: `https://undocrm.com/mcp` (Streamable HTTP, stateless). Auth: `Authorization: Bearer ucrm_agt_...`. Owner keys are refused; MCP is for agents. Five tools: **read**, **create**, **update**, **delete**, and **can_contact**. The first four are plain CRUD over HubSpot-style objects (contact, company, deal, task, interaction, pipeline). `can_contact` is separate because it can't be CRUD: it checks and claims a person in one locked step. Results are short plain text written for a model, always carrying the ids the next call needs. Errors come back as tool errors that say what to do instead. **`person`** / **`target`** strings: | You have | Pass | | --- | --- | | Contact id | `ct_3f9a0c2b7e1d4a58` | | Email | `jane@acme.com` | | Phone | `+14155550100` (any formatting; E.164 matches most reliably) | | Handle or profile URL | `linkedin:janedoe`, `x:@jane`, `https://linkedin.com/in/janedoe` | | Other records | `co_…` company, `dl_…` deal, `fu_…` task, `ix_…` interaction | | Collections (read only) | `contacts`, `companies`, `deals`, `tasks`, `interactions`, `pipelines`, `properties` | Property names follow HubSpot: `firstname`, `lastname`, `email`, `phone`, `jobtitle`, `company`, `lifecyclestage`; `dealname`, `dealstage`, `amount`, `closedate`, `pipeline`. Anything else you set on a contact is a **custom property**: stored append-only with history, and registered so other agents reuse the same name. --- ## read | Param | | | | --- | --- | --- | | `target` | string | A person, an id, or a collection. Omit with `query` to search contacts. | | `view` | `brief` \| `logs` \| `facts` \| `deals` \| `tasks` | For a person. Default `brief`. | | `query` | string | Search text for `contacts`, `companies`, or `interactions` (full-text over every conversation). | | `purpose` | string | For briefs: why you're looking. Ranks what goes in. | | `max_tokens` | 80–8000 | For briefs. Default 500. Rules are always included. | | `filter` | object | For collections: tasks `{due: overdue\|today\|week\|all, mine}`, deals `{pipeline, dealstage, open}`, contacts `{lifecyclestage}`. | | `limit` | 1–100 | Default 20. | A person's brief — the read every agent should do before acting: ```text # Jane Doe — VP Customer Support, Acme (acme.com) ct_b2b23e68a58e3aa2 · jane.doe@acme.com · +14155550142 · linkedin:janedoe-acme · lifecyclestage: opportunity ## Rules - Claimed by sdr-1 until 2026-10-19 02:09Z (send SOC2 + order form). Don't reach out; they are handling this person. - Last outbound: meeting 1d ago by sdr-1. ## Open tasks - fu_2c26f224f1c50252 due 2026-10-18: Send SOC2 report and pilot order form [email] (sdr-1) ## Deals - dl_bbcd208139c1ddc5 Acme — support pilot: Contract Sent · 80,000 · close 2026-10-31 ## Facts - budget: $80k/yr (was: $50k/yr) — sdr-1, 2026-10-17 - decision_maker: CFO (Mark Ellis) — sdr-1, 2026-10-17 ## Recent interactions (4 of 4; → outbound, ← inbound) - 2026-10-15 meeting → sdr-1: 30-min call. Budget approved at $80k/yr after the Zendesk quote came in higher. ``` Searching every conversation: ```text read(target: "interactions", query: "SOC2") 2 conversations mention "SOC2": ix_9f2… 2026-10-13 phone ← Jane Doe (ct_b2b…): …wanted to know if «SOC2» is done. ix_41c… 2026-10-15 meeting → Jane Doe (ct_b2b…): …needs «SOC2» Type II before signing. ``` Your tasks: `read(target: "tasks", filter: {due: "today"})`. The deal board: `read(target: "deals", filter: {dealstage: "contractsent"})`. Which property names exist: `read(target: "properties")`. ## create | Param | | | | --- | --- | --- | | `type` | `interaction` \| `contact` \| `company` \| `deal` \| `task` \| `pipeline` | required | | `person` | string | Who it's about (interaction, deal, task). Unknown people are created. | | `properties` | object | The record's fields (below). | | `facts` | object | interaction only: what you learned, as `{budget: "$50k", timeline: "Q1"}`. Never overwrites. | | `tasks` | `[{due, subject, channel?}]` | interaction only: what you owe. `due`: ISO date, datetime, or `"3d"`, `"2w"`, `"4h"`. | | `completes_tasks` | `string[]` | interaction only: task ids this interaction fulfills. | **interaction** — the main write, after every touchpoint. One call records the conversation, what you learned and what you owe. ```json { "type": "interaction", "person": "jane@acme.com", "properties": { "channel": "email", "direction": "inbound", "summary": "Replied: budget approved at $80k, needs our SOC2 report before signing.", "external_id": "" }, "facts": { "budget": "$80k/yr", "blocker": "Needs SOC2 report" }, "tasks": [{ "due": "2026-10-20", "subject": "Send the SOC2 report", "channel": "email" }], "completes_tasks": ["fu_1a2b3c4d5e6f7a8b"] } ``` ```text Logged ix_e543dde288ec2cfd with Jane Doe (ct_8c77757b98080b2f). Facts: budget (was: $50k/yr), blocker. Tasks: fu_9d0e1f2a3b4c5d6e due 2026-10-20 "Send the SOC2 report". Completed: fu_1a2b3c4d5e6f7a8b. ``` `properties` for an interaction: `channel`, `direction` (`inbound`, `outbound`, `internal`), `summary` (1–3 sentences for a future agent), and optionally `occurred_at`, `content` (raw text, never put in briefs), `external_id` (makes retries safe), `thread_id`, `deal_id`, `name` (if the person is new). If a new fact name looks like one that already exists, the result says so — `"annual_budget" is new; "budget" already exists` — so agents converge on one vocabulary. **contact** — `{email, phone, linkedin, firstname, lastname, jobtitle, company, lifecyclestage, …any custom property}`. Matches existing people by identity; if the identities belong to different records they're merged into one. If a company was inferred from an email domain, the result says so. **deal** — `person` + `{dealname, dealstage, amount, closedate, pipeline?, currency?}`. Without `pipeline` the deal goes in the first one, HubSpot's default Sales Pipeline: `appointmentscheduled → qualifiedtobuy → presentationscheduled → decisionmakerboughtin → contractsent → closedwon / closedlost`. Stage keys or labels both work (`"Contract sent"`). **task** — `person` + `{subject, due, channel?, deal_id?}`. **company** — `{name, domain, …any custom property}`. **pipeline** — `{label, stages: ["Applied", "Screen", "Onsite", "Offer", "Hired", "Rejected"]}`. For recruiting, support queues, anything with stages. ## update | Param | | | | --- | --- | --- | | `target` | string | A person or an id. | | `properties` | object | Only what changes. | - **person** — `jobtitle`, `company`, `lifecyclestage`, `name`; `email`/`phone`/`linkedin` add identities (and merge duplicates); custom properties append (old value kept); `do_not_contact: "all"` or a channel, with `do_not_contact_reason`. Agents can't lift do-not-contact. - **dl_ deal** — `dealstage`, `amount`, `closedate`, `dealname`, `pipeline`. - **fu_ task** — `{status: "done" | "cancelled", outcome}`, or `{due, subject}` to reschedule. - **co_ company** — `name`, `domain`, custom properties. ```text update("dl_bbcd208139c1ddc5", {dealstage: "closedwon", amount: 85000}) → Updated dl_bbcd208139c1ddc5 "Acme — support pilot": stage Contract Sent → Closed Won, amount 85000. ``` Opt-outs, the moment they happen: ```text update("marcus@globex.io", {do_not_contact: "email", do_not_contact_reason: "Replied: please stop emailing me"}) → Now do-not-contact on email; every agent's can_contact will say BLOCKED. ``` ## delete Nothing is destroyed: records are archived or retracted, and a human can restore them from the audit log. | Param | | | | --- | --- | --- | | `target` | string | Person, `dl_`, `fu_`, or `ix_`. | | `property` | string | For a person: retract only this property's current value. | | `reason` | string | Required to archive a person. | ```text delete("jane@acme.com", property: "budget", reason: "typo") → Retracted budget = "$500k" for Jane Doe. Current value is now the previous one: "$50k". History is kept. ``` ## can_contact The gate before every outbound message. | Param | | | | --- | --- | --- | | `person` | string | required | | `channel` | string | required unless releasing | | `purpose` | string | Shown to agents your claim blocks. | | `claim` | bool | Default `true`. `false` only checks. | | `release` | bool | `true` drops your claim. | | `ttl_minutes` | int | Claim length. Default 1440 (24h), max 43200. | ```text ALLOWED — Jane Doe (ct_8c77757b98080b2f) on email. Claimed for you until 2026-10-18T09:00:00Z. Last outbound: email 2d ago by sdr-1. After sending, log it with create(type: "interaction"). ``` ```text BLOCKED — Jane Doe (ct_8c77757b98080b2f) is claimed by sdr-1 until 2026-10-18T09:00:00Z (purpose: send SOC2 report). Another agent is handling them; don't message. ``` ```text BLOCKED — Marcus Chen (ct_bb1e9ab17925a336) is do-not-contact on email: "Replied: please stop emailing me" (sdr-1, 2026-10-17). Do not message them on email. ``` --- ## Errors ```text Error: "@jane" is ambiguous. Prefix handles with their platform, e.g. x:@jane or linkedin:jane. Error: "on the moon" is not a stage of "Sales Pipeline". Stages: appointmentscheduled, qualifiedtobuy, … Error: Agents can't lift do-not-contact; only a human can, in the web app. Everything else was saved. ``` ## Classic tool set The original nine task-named tools (`get_brief`, `log_interaction`, `save_contact`, `search_contacts`, `set_do_not_contact`, `release_contact`, `list_follow_ups`, `complete_follow_up`, `can_contact`) are still served at `https://undocrm.com/mcp?tools=classic` for existing integrations. They have no deals, pipelines, conversation search or property correction. See DECISIONS.md for how the two sets compared in evals. --- # HTTP API Base URL: `https://undocrm.com/v1`. JSON in, JSON out. Auth: `Authorization: Bearer `. The agent endpoints have the same semantics as the [MCP tools](/docs/mcp-tools), but return structured JSON — use them from backends, queues and agent frameworks without MCP. Errors look like: ```json { "error": { "code": "invalid_input", "message": "Could not read \"@jane\" as a person. …" } } ``` | Status | Code | | --- | --- | | 400 | `invalid_input` | | 401 | `unauthorized` — missing, invalid or revoked key | | 403 | `forbidden` — e.g. an agent key on an owner endpoint | | 404 | `not_found` | | 409 | `conflict` — e.g. undoing something twice | ## Pagination Lists page with keyset cursors, so every page is as fast as the first, even at millions of rows. Pass `limit` (default 50, max 200); the response includes `next_cursor`, which you send back unchanged as `?cursor=` for the next page. `next_cursor: null` means you've reached the end. ```bash curl "https://undocrm.com/v1/contacts?limit=100" -H "Authorization: Bearer $KEY" # → { "contacts": [...], "next_cursor": "eyJ0IjoiMjAyNi0xMC0xNyAwMjo1NDozMi44NjQ3MjMrMDAiLCJpZCI6ImN0X..." } curl "https://undocrm.com/v1/contacts?limit=100&cursor=eyJ0Ijoi..." -H "Authorization: Bearer $KEY" ``` | List | Sorted by | Extra filters | | --- | --- | --- | | `GET /contacts` | last activity, newest first | `lifecyclestage` (or `none`) | | `GET /contacts?q=` | relevance (cursor is an offset; results are capped at a few hundred) | | | `GET /follow-ups` | due date, soonest first | `due`, `mine`, `person` | | `GET /deals/column` | last updated, newest first | `pipeline`, `dealstage` (required) | | `GET /contacts/:id/timeline` | newest first: interactions older than the cursor from `GET /contacts/:id`, with the events in the same window | | | `GET /audit` | newest first; pages with `before=` | `contact_id`, `agent_id` | Totals for headers come from cheap count endpoints: `GET /contacts/groups` (contacts per lifecycle stage), `GET /follow-ups/counts` (overdue / today / upcoming), and the `totals` in `GET /deals/board`. ## Authentication | Caller | Header | Reaches | | --- | --- | --- | | Agent | `Authorization: Bearer ucrm_agt_…` | Workspace endpoints (as that agent), MCP | | Backend, one workspace | `Authorization: Bearer ucrm_own_…` | Workspace endpoints, including administration | | Backend, organization | `Authorization: Bearer ucrm_org_…` | `POST/GET /workspaces` | | Signed-in person | `Authorization: Bearer ` and `X-Workspace-Id: ws_…` | Account endpoints; workspace endpoints for workspaces in their organizations, by role | ## Workspaces for your customers — org key ### `POST /workspaces` ```bash curl -X POST https://undocrm.com/v1/workspaces \ -H "Authorization: Bearer $ORG_KEY" -H "content-type: application/json" \ -d '{"name": "Acme Recruiting"}' ``` ```json { "workspace": { "id": "ws_cd7b82dcfdcc4ed0", "name": "Acme Recruiting", "org_id": "org_…", "created_at": "…" }, "owner_key": "ucrm_own_…" } ``` The owner key is shown once; use it to create that workspace's agents. `GET /workspaces` lists your organization's workspaces. ## Accounts — signed-in people These power the web app. All take a signed-in session token; none take API keys. | Method | Path | Body | | | --- | --- | --- | --- | | GET | `/me` | | You, your organizations (with role and workspaces), invites waiting for you | | PATCH | `/me` | `{name}` | | | POST | `/orgs` | `{name, workspace_name?}` | New organization with a first workspace; you're its owner | | PATCH | `/orgs/:org` | `{name}` | Admin | | GET | `/orgs/:org/members` | | Members and pending invites | | PATCH | `/orgs/:org/members/:user` | `{role}` | Admin; only owners change owners | | DELETE | `/orgs/:org/members/:user` | | Admin, or yourself to leave. The last owner can't leave | | POST | `/orgs/:org/invites` | `{email, role}` | Admin. Returns `{url, emailed}` | | DELETE | `/orgs/:org/invites/:id` | | Admin | | GET | `/invites/:token` | | Public preview: organization, inviter, masked email | | POST | `/invites/:token/accept` | | Must be signed in as the invited email | | GET/POST | `/orgs/:org/workspaces` | `{name}` | List; create (admin) | | PATCH | `/orgs/:org/workspaces/:ws` | `{name}` | Admin | | GET/POST/DELETE | `/orgs/:org/keys[/:id]` | `{label}` | Org API keys (admin) | | GET | `/orgs/:org/audit` | `?before` | Organization-level changes (admin) | ## Workspace administration — owner key or admin | Method | Path | Body | | | --- | --- | --- | --- | | GET | `/workspace` | | Name, your role, counts. Works with any key. | | GET | `/agents` | | Agents with key prefixes, last use, activity | | POST | `/agents` | `{name, description?}` | Returns `{agent, api_key}`; key shown once | | POST | `/agents/:id/keys` | | Another key for an agent (rotation) | | POST | `/owner-keys` | `{label?}` | Another owner key | | DELETE | `/keys/:id` | | Revoke; effective within 30s | | GET | `/audit` | `?limit&before&contact_id&agent_id` | Newest first; page with `before=` | | POST | `/audit/:id/undo` | | Reverse one action | | DELETE | `/contacts/:id/do-not-contact/:blockId` | | Lift a block (humans only) | ## People — any key | Method | Path | Body / query | | | --- | --- | --- | --- | | GET | `/contacts` | `?q&limit` | Search; without `q`, most recently active | | POST | `/contacts` | `{person?, identities?, name?, title?, company?, facts?}` | Create/update/merge. 201 if created | | GET | `/contacts/:person` | | Full record: identities, interactions, facts (with history), follow-ups, blocks, claim, audit | | POST | `/contacts/:person/do-not-contact` | `{reason, channel?}` | | `:person` accepts any person string, URL-encoded (`/contacts/jane%40acme.com`). ## Deals and pipelines — any key | Method | Path | Body / query | | | --- | --- | --- | --- | | GET | `/pipelines` | | Pipelines with ordered stages (`key`, `label`, `probability`, `is_closed`) | | POST | `/pipelines` | `{label, stages: ["Applied", …]}` | | | GET | `/deals` | `?pipeline&dealstage&person&open=true` | | | POST | `/deals` | `{person?, dealname, pipeline?, dealstage?, amount?, closedate?, currency?, company?}` | | | GET | `/deals/:id` | | | | PATCH | `/deals/:id` | any of the above | Stage key or label | | DELETE | `/deals/:id` | | Archive (undoable) | | GET | `/properties` | | The property registry, with usage counts | | GET | `/interactions/search` | `?q=` | Full-text search over every conversation | | GET | `/overview` | | Owner key: 14-day activity, agents, pipeline funnel (powers the web home page) | ## Agent operations — agent key (owner keys can read) ### `GET /brief?person=&purpose=&max_tokens=` ```json { "text": "# Jane Doe — VP Engineering, Acme …", "contact_id": "ct_…", "tokens": 172, "omitted": { "facts": 0, "interactions": 0, "follow_ups": 0 } } ``` ### `POST /can-contact` ```json { "person": "jane@acme.com", "channel": "email", "purpose": "send SOC2 report" } ``` ```json { "allowed": true, "reason": "ok", "contact_id": "ct_…", "channel": "email", "block": null, "claim": { "id": "cl_…", "agent_id": "ag_…", "agent_name": "sdr-1", "purpose": "send SOC2 report", "expires_at": "…" }, "last_outbound": { "channel": "email", "occurred_at": "…", "agent_name": "sdr-1" } } ``` `reason` is one of `ok`, `do_not_contact`, `claimed_by_other_agent`. ### `POST /release` `{ "person": "jane@acme.com" }` → `{ "contact_id": "ct_…", "released": true }` ### `POST /interactions` Body as in [`log_interaction`](/docs/mcp-tools), plus optional `source` (default `agent`; sync integrations set their own, e.g. `gmail`). Returns 201: ```json { "duplicate": false, "interaction_id": "ix_…", "contact_id": "ct_…", "created_contact": false, "facts": [{ "id": "fa_…", "key": "budget", "value": "$80k/yr", "previous": "$50k/yr" }], "follow_ups": [{ "id": "fu_…", "action": "Send the SOC2 report", "due_at": "…" }], "completed_follow_ups": [], "warning": null } ``` A repeated `external_id` (per `source`) returns `{ "duplicate": true, … }` with 200. ### `GET /follow-ups?due=&mine=&person=&limit=` `due`: `overdue`, `today` (default), `week`, `all`. `mine` defaults to `true` for agent keys. ### `POST /follow-ups/:id/complete` `{ "status": "done" | "cancelled", "outcome": "…" }` --- # Performance Speed is the product: fewer calls, fewer tokens, no waiting. UndoCRM is benchmarked with **one workspace holding 1,000,000 contacts** and their history: | Records | Count | | --- | --- | | Contacts | 1,000,000 | | Identities (emails, phones, handles) | 1,700,000 | | Interactions | 3,000,000 | | Facts | 2,000,000 | | Audit log entries | 2,000,000 | | Deals | 100,000 | | Tasks | 200,000 | ## Results Server-side times, 10 runs each. The benchmark fails if a search returns nothing or the wrong person first, so these are times for correct answers. Network time between your agent and UndoCRM comes on top. | Operation | p50 | p95 | | --- | --- | --- | | Brief by email | 3ms | 5ms | | Brief by id, over MCP | 4ms | 7ms | | Search by name | 9ms | 28ms | | Search by name with a typo ("Prya Patl") | 37ms | 51ms | | Search by partial email | 2ms | 3ms | | Search by company domain | 5ms | 6ms | | `can_contact` + claim | 5ms | 8ms | | Log an interaction (+facts, +task) | 5ms | 13ms | | Log an interaction with a new person | 4ms | 5ms | | Search conversations | 4ms | 5ms | | Deals in a stage | 25ms | 27ms | **Concurrent writes:** 100 interactions from two agents at once, half of them creating new people: about 600 writes a second, no failures, no duplicate contacts. ## Known limits - **Typos that swap letters** ("Pryia" for "Priya") match poorly; missing and extra letters match well. Agents usually search by email or id, which are exact. - **Request rates** depend on your [plan](/#pricing).