Docs
API reference
Everything the console does goes through the same HTTP API your agent uses, at /api on this origin. Requests and responses are JSON. New here? Start with a quickstart: Claude Code, TypeScript SDK, or curl and Python.
Connect an agent
- Sign in, open Agents, name the agent and choose its scopes.
- Copy the key. It is shown once and stored hashed.
- Add Deskhand to your agent. For Claude Code, one line:
claude mcp add --transport http deskhand https://deskhand.grain64.com/api/mcp \ --header "Authorization: Bearer $DESKHAND_KEY"
The agent shows as connected in the console after its first call.
Any MCP client
The endpoint is POST https://deskhand.grain64.com/api/mcp: MCP over streamable HTTP, JSON-RPC, one JSON response per request, no session to keep. Authenticate with the agent key as a bearer token. A generic client configuration:
{
"mcpServers": {
"deskhand": {
"type": "http",
"url": "https://deskhand.grain64.com/api/mcp",
"headers": { "Authorization": "Bearer ${DESKHAND_KEY}" }
}
}
}
Protocol versions 2025-06-18, 2025-03-26 and 2024-11-05 are accepted. Tools are checked against the key's scopes, its rate limit and its pause state on every call. A refused call comes back as a tool error with a machine-readable code, so the agent can act on it. A paused or revoked agent gets an HTTP 403 instead.
MCP tools
| Tool | Scope | What it does |
|---|---|---|
search_contacts | read_crm | Find contacts by text, status, source, company or a custom field, with a cursor for more |
upsert_contact | write_crm | Create or update by email; links or creates the company from company_domain or company_name; fields and tags merge |
update_contact | write_crm | Change a contact found by id or email |
create_deal, move_deal | write_crm | Open a deal, optionally linked to a contact by id or email; move it between stages |
add_note | write_crm | Attach a note to a contact or deal |
Write tools accept an optional idempotency_key: repeating a call with the same key and arguments returns the first result instead of writing again. Every write is recorded in Activity with the agent, the run and the before and after values.
Plain HTTP
The same operations are available as REST calls on this origin, and the console uses them too:
curl https://deskhand.grain64.com/api/me \ -H "Authorization: Bearer $DESKHAND_KEY"
Import and export
The Import screen takes a CSV, shows what would be created, filled in or skipped before anything is written, and undoes the whole import in one click. HubSpot and Attio contact exports are recognised and mapped; columns with no home become custom fields. Settings downloads everything as one JSON file, or any record type as CSV.
Scopes
| Scope | Allows |
|---|---|
read_crm | Search and read contacts, companies, deals, notes |
write_crm | Create, upsert, update and delete those records |
draft_email, send_email | Email drafting and sending |
manage_tasks | Tasks and reminders |
CRM endpoints
| Endpoint | Purpose |
|---|---|
GET /api/contacts | List. Filters: q, status, source, company_id, field.<name>=<value>, limit, cursor |
POST /api/contacts/upsert | Create or update by email. Optional company_name and company_domain link or create the company. fields merge into existing custom fields |
POST /api/contacts, GET/PATCH/DELETE /api/contacts/:id | Create, read, update, delete |
/api/companies, POST /api/companies/upsert | Same shape, de-duplicated by domain |
/api/deals | Stages: lead, contacted, qualified, proposal, won, lost. Move a deal with PATCH {"stage":"qualified"} |
POST /api/notes, GET /api/notes?contact_id= | Notes on a contact or deal |
Contacts and deals take a free-form source tag and a small fields object for custom columns.
Retries and runs
Send an Idempotency-Key header on writes. Repeating the same request with the same key returns the stored response instead of writing again; reusing a key with a different body returns 422.
Calls are grouped into runs. Send X-Run-Id (and optionally X-Run-Label) to name a run yourself; otherwise calls within 30 minutes of each other share one.
Errors
Errors return {"error":{"code","message","request_id"}}.
| Status | Code | Meaning |
|---|---|---|
| 401 | invalid_key, key_revoked | Unknown key, or a key that was rotated or revoked |
| 403 | agent_paused, agent_revoked | The operator paused or revoked this agent. Stop and report it |
| 403 | missing_scope | The key lacks the scope named in required_scope |
| 403 | operator_only | Setting that field is reserved to the human operator |
| 429 | rate_limited | Per-minute limit for this agent reached |