CRM
Your sales pipeline: the companies, the people, the deals in play.
Also for this capability: API · CLI · MCP · SDKs
Your sales pipeline: the companies, the people, the deals in play.
| Base URL | https://api.hanzo.ai |
| Operations | 20 |
| Auth | Authorization: Bearer $HANZO_API_KEY |
Specification
HIP-1120 · CRM — The Sales Pipeline — Draft · read the specification →
/v1/crm is an org's sales pipeline: companies, contacts and opportunities,
plus the Startup Program intake that lands as a scored application. It is
implemented in hanzoai/cloud at apps/crm. This HIP states the store, the two
audiences the surface serves — staff behind the identity boundary and an
anonymous applicant on one public form — and why exactly one route is not typed.
Motivation
A CRM contact is a prospect the org tracks, not a product user: the org's own
users live in IAM, and the marketing subsystem resolves audiences from that
roster, never from this table (apps/crm/crm.go:9-13). Without a capability
that owns the prospect universe, prospect rows leak into user stores and the two
contact universes join by accident — which is a privacy defect, not a modelling
choice. This capability is the one place prospects live.
Specification
The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as in RFC 2119.
§1 The store
crm owns one store: a single SQLite file named crm in the deployment's data
directory (apps/crm/store.go:40, sqlpool.Open). Every org's rows share the
file; isolation is the org column, which leads every uniqueness and lookup
index so tenancy is a physical property of the index, not only a WHERE clause
(apps/crm/store.go:56-59). The entity model follows Twenty's company /
person / opportunity standard objects, with the composite fields flattened
to scalar columns for SQLite (apps/crm/crm.go:5-8); no upstream code is
imported for it.
§2 The addresses
Every route is under /v1/crm (manifest/apps.go:273). The CRUD over
companies, contacts and opportunities, GET /v1/crm/summary, and the staff
reads of applications are typed operations; each DELETE answers no body and so
carries no response schema. One route is a raw handler and MUST stay one:
POST /v1/crm/applications, the public Startup Program intake. Its rate limit
and 64 KiB body cap are HTTP middleware, and the MCP and CLI projections of a
typed op do not run middleware — typing it would publish an unmetered alias of a
deliberately limited public endpoint (apps/crm/crm.go:200-207). Its prose is
declared beside the wire fact instead (apps/crm/applications.go:130).
§3 Tenancy
Staff routes resolve the org from the validated principal
(principal.Acting, apps/crm/crm.go:433), minted by the identity boundary
(HIP-0026); a request the boundary refuses never reaches the store. The intake
POST is the one unauthenticated route: it takes no principal and never reads a
caller org — the application is filed against the deployment's own program org,
so there is no tenant to name and none to leak. Re-submitting the same
(email, company) refreshes the existing application rather than filing a second.
§4 Money, events, telemetry
crm is free, in those words: its plugin declares cloud.Free
(plugin/crm/main.go:21) and crm is not in the metered set (spend.go:275).
The AI screen a filed application receives runs on the deployment's own gateway
credential, not the applicant's. crm publishes no events on the bus, and emits
nothing to observability beyond the request span every route gets.
§5 Stage
crm is beta: a vertical application, not the agentic-OS core. The manifest
row declares it (manifest/apps.go:273, Stage: Beta; HIP-0139 §8).
§6 Upstream
crm derives from no third-party code. The one third-party fact is the schema lineage stated in §1: the entity model mirrors Twenty's standard objects so a migration is a column mapping, and nothing of Twenty's implementation is in the tree.
Rationale
One shared file with a leading org index, rather than a file per org, because
CRM rows are small and the summary read is a cross-table count within one org —
the per-org-file pattern buys physical isolation at the cost of N file handles,
and here the leading index gives the same fail-closed property for one handle.
The intake staying raw, rather than teaching typed ops about middleware, keeps
the typed registry's promise intact: every typed op is safe to project
everywhere.
Security Considerations
The wrong implementation leaks the pipeline: every prospect, every deal and its amount, to any tenant that can name another's id. The gate is the org from the validated principal, never a client-supplied field, and every query filters on it. The intake is the other exposure — an unauthenticated write — and it is bounded three ways: IP rate limit, body cap, and upsert-on-resubmit, so an attacker can neither flood the store nor amplify a row.
Four surfaces
| Surface | Reaches this capability as | Coverage |
|---|---|---|
| REST | crm at its own prefix | 20 operations |
| CLI | hanzo crm … | 20 of 20 |
| SDK | — | no published client declares one yet — regenerating the clients is what adds them |
| MCP | tool crm on https://api.hanzo.ai/v1/mcp | 20 operations, 1 under the document's own id — ask describe for the rest |
Quickstart
export HANZO_API_KEY=sk-... # console.hanzo.ai → API keysThen the first call — a read that needs nothing but the key. GET /v1/crm/summary, operation get_crm_summary:
hanzo crm summaryimport { Configuration, CrmApi } from 'hanzoai';
const api = new CrmApi(new Configuration({ accessToken: process.env.HANZO_API_KEY }));
const { data } = await api.getCrmSummary();from hanzoai.cloud import ApiClient, Configuration
from hanzoai.cloud.api import CrmApi
client = ApiClient(Configuration(access_token=os.environ["HANZO_API_KEY"]))
result = CrmApi(client).get_crm_summary()cfg := cloud.NewConfiguration()
cfg.AddDefaultHeader("Authorization", "Bearer "+os.Getenv("HANZO_API_KEY"))
client := cloud.NewAPIClient(cfg)
resp, _, err := client.CrmAPI.GetCrmSummary(context.Background()).Execute()
if err != nil {
return err
}use hanzo_cloud::apis::{configuration::Configuration, crm_api};
let mut cfg = Configuration::new();
cfg.bearer_access_token = std::env::var("HANZO_API_KEY").ok();
let result = crm_api::get_crm_summary(&cfg, Default::default()).await?;import ai.hanzo.cloud.ApiClient;
import ai.hanzo.cloud.api.CrmApi;
ApiClient client = new ApiClient();
client.setRequestInterceptor(b -> b.header("Authorization", "Bearer " + System.getenv("HANZO_API_KEY")));
var result = new CrmApi(client).getCrmSummary();The method above is the one at the current release of the document. [email protected] (npm) was generated from an earlier release, where this operation carried a different id, so it spells the method differently — regenerating the clients is what makes the two agree. SDKs →
curl https://api.hanzo.ai/v1/crm/summary \
-H "Authorization: Bearer $HANZO_API_KEY"Tool crm, op get_crm_summary — POST the JSON-RPC envelope to https://api.hanzo.ai/v1/mcp.
curl -X POST https://api.hanzo.ai/v1/mcp \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "crm",
"arguments": {
"op": "get_crm_summary",
"input": {}
}
}
}'Answers 200 with object — ok.
Endpoints
| Endpoint | What it does |
|---|---|
GET /v1/crm/applications/{id} | Returns one Startup Program application with its AI screen and stage history. |
PATCH /v1/crm/applications/{id} | Moves one Startup Program application through the pipeline. |
GET /v1/crm/applications | Returns the org's Startup Program applications, newest first. |
POST /v1/crm/applications | Apply to the Startup Program from the public form |
GET /v1/crm/companies/{id} | Returns one of the caller org's companies. |
PUT /v1/crm/companies/{id} | Replaces one of the caller org's companies. |
DELETE /v1/crm/companies/{id} | Removes one of the caller org's companies and answers 204. |
GET /v1/crm/companies | Returns the caller org's companies, most recently updated first. |
POST /v1/crm/companies | Adds a company to the caller's org and answers 201 with the stored record. |
GET /v1/crm/contacts/{id} | Returns one of the caller org's contacts. |
PUT /v1/crm/contacts/{id} | Replaces one of the caller org's contacts. |
DELETE /v1/crm/contacts/{id} | Removes one of the caller org's contacts and answers 204. |
GET /v1/crm/contacts | Returns the caller org's contacts, most recently updated first. |
POST /v1/crm/contacts | Adds a person to the caller's org and answers 201 with the stored record. |
GET /v1/crm/opportunities/{id} | Returns one of the caller org's deals. |
PUT /v1/crm/opportunities/{id} | Replaces one of the caller org's deals. |
DELETE /v1/crm/opportunities/{id} | Removes one of the caller org's deals and answers 204. |
GET /v1/crm/opportunities | Returns the caller org's deals, most recently updated first. |
POST /v1/crm/opportunities | Adds a deal to the caller's org and answers 201 with the stored record. |
GET /v1/crm/summary | Summary counts the caller org's CRM records: companies, contacts, opportunities. |
How is this guide?