deskhand

Guide

Connect an agent to a CRM over MCP with the TypeScript SDK

If your agent is a program rather than a terminal session, the official MCP SDK is the shortest path to Deskhand's tools. The endpoint is streamable HTTP with one JSON response per request, so there is no session to keep and nothing to reconnect.

1. Get a key

Sign in, open Agents, name the agent and keep the default scopes (read_crm, write_crm). Copy the key; it is shown once. Create a free workspace if you have not.

2. Install the SDK

npm install @modelcontextprotocol/sdk

3. Save and search a contact

Save this as demo.mjs:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const transport = new StreamableHTTPClientTransport(
  new URL("https://deskhand.grain64.com/api/mcp"),
  { requestInit: { headers: { Authorization: `Bearer ${process.env.DESKHAND_KEY}` } } },
);
const client = new Client({ name: "my-agent", version: "1.0.0" });
await client.connect(transport);

const { tools } = await client.listTools();
console.log(tools.map((t) => t.name).join(", "));

const saved = await client.callTool({
  name: "upsert_contact",
  arguments: { email: "[email protected]", name: "Ada", source: "sdk-quickstart", idempotency_key: "sdk-quickstart-ada-1" },
});
if (saved.isError) throw new Error(JSON.stringify(saved.content));
console.log("saved", saved.structuredContent.contact.id);

const found = await client.callTool({ name: "search_contacts", arguments: { q: "ada" } });
console.log("found", found.structuredContent.items.map((c) => c.email));
await client.close();
DESKHAND_KEY=dh_live_... node demo.mjs

The first line prints the tool names the key can see. The agent flips to connected in the console after this first call, and the contact is in Contacts with the agent named in Activity.

What failures look like

A refused tool call comes back with isError set and a JSON body the agent can read. A key without the right scope returns missing_scope and names the scope it needed:

{"error":{"code":"missing_scope","message":"This agent key does not have the \"draft_email\" scope. ...","required_scope":"draft_email"}}

A paused or revoked agent gets an HTTP 403 (agent_paused), which the SDK raises as an error. Stop and report it rather than retrying. The full tool list 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: A CRM for Claude Code agents: one command, one key per agent, A CRM API for AI agents: curl and Python quickstart