Guide
A CRM API for AI agents: curl and Python quickstart
Not every agent speaks MCP. Everything the console does goes through the same HTTP API on this origin, authenticated with the agent's key as a bearer token, so a shell script, a cron job or a Python loop can be the agent.
curl
export DESKHAND_KEY=dh_live_...
curl -X POST https://deskhand.grain64.com/api/contacts/upsert \
-H "Authorization: Bearer $DESKHAND_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: lead-ada-001" \
-d '{"email":"[email protected]","name":"Ada","company_name":"Example Ltd","source":"first-run"}'
A new contact returns 201 with "created": true and the contact. Upsert matches on email, links or creates the company from company_name or company_domain, and merges custom fields into what is already there. Search it back:
curl "https://deskhand.grain64.com/api/contacts?q=ada&limit=5" -H "Authorization: Bearer $DESKHAND_KEY"
Python, standard library only
import json, os, urllib.error, urllib.parse, urllib.request
BASE = "https://deskhand.grain64.com/api"
KEY = os.environ["DESKHAND_KEY"]
def call(method, path, body=None, headers=None):
req = urllib.request.Request(
BASE + path,
data=json.dumps(body).encode() if body is not None else None,
method=method,
headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json",
"User-Agent": "my-agent/1.0", **(headers or {})},
)
try:
with urllib.request.urlopen(req) as r:
return json.load(r)
except urllib.error.HTTPError as e:
err = json.load(e)["error"]
if err["code"] == "agent_paused":
raise SystemExit("Deskhand says this agent is paused. Stop and report it.")
raise SystemExit(f"{e.code} {err['code']}: {err['message']}")
saved = call("POST", "/contacts/upsert",
{"email": "[email protected]", "name": "Grace", "source": "py-quickstart"},
{"Idempotency-Key": "py-quickstart-grace-1"})
print("saved", saved["contact"]["id"], "created" if saved["created"] else "already there")
found = call("GET", "/contacts?" + urllib.parse.urlencode({"q": "grace", "limit": 5}))
print("search found", [c["email"] for c in found["items"]])
Set a User-Agent of your own, as above: the default Python-urllib one is refused at the network edge before it reaches the API.
Retries and runs
Send an Idempotency-Key on writes. Repeating the same request with the same key returns the stored response instead of writing again, and reusing a key with a different body returns 422. Calls within 30 minutes of each other share a run in Activity; send X-Run-Id and X-Run-Label to name one yourself.
Errors an agent can act on
Errors are {"error":{"code","message","request_id"}}. Branch on code: agent_paused and agent_revoked (HTTP 403) mean stop, missing_scope names required_scope, rate_limited (429) means wait. The full table is in the API reference.
Try it
The Free plan covers one agent with its own key, a pause switch and the full audit log. Get started free
Related: Connect an agent to a CRM over MCP with the TypeScript SDK, An MCP server for a CRM: per-agent keys, scopes and a pause switch