deskhand

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