The real problem
Why this is harder than it looks
The Close REST API is clean and well-structured. You can get a prototype querying leads and logging calls against your own account in an afternoon. The complexity hits when you try to run this for real users in a multi-tenant product.
Close uses OAuth 2.0 with access tokens that expire after one hour. In a single-user context, that's trivial to handle. In a multi-tenant product where each of your customers' sales reps connects their own Close account, you need per-user token storage that is securely isolated across tenants — one rep's tokens must never be accessible to another, even within the same organization. You also need refresh logic that runs proactively before expiry, not reactively after a failed call that a rep is waiting on. If a user revokes access in Close, you need to detect it and surface a re-authorization prompt rather than silently returning errors.
On top of that, Close's OAuth app setup requires you to register your own OAuth application, supply a redirect URI, and store your own Client ID and Secret — there's no shared managed app you can borrow. That's fine in isolation, but it means your codebase is now managing OAuth app credentials, per-user access tokens, per-user refresh tokens, and all the failure modes that come with them — before you've written a single lead query.
Scalekit handles the OAuth flow, per-user token storage, automatic refresh, and revocation detection. Your agent names a tool and passes parameters. The auth plumbing is not your problem.
Capabilities
What your agent can do with Close
Once connected, your agent has 81 pre-built tools covering the full Close CRM object model:
- Manage leads end-to-end: create, retrieve, update, delete, merge, and search leads with full-text query support
- Track contacts and opportunities: create contacts with email and phone, manage deals through pipeline stages with value and confidence tracking
- Log all activity types: record calls, emails, SMS, and notes on leads; update statuses and link to contacts
- Automate with sequences: enroll contacts in email sequences, monitor subscription state, pause or resume enrollment
- Manage tasks and follow-ups: create, assign, and complete tasks with due dates; filter by view, type, or assignee
- Inspect org structure: list users, pipelines, custom fields, and webhook subscriptions
Setup context
What we're building
This guide connects a sales assistant agent to Close — helping reps query their pipeline, log activities, and manage leads without leaving your product.
🤖
Example agent
Sales assistant querying leads, logging calls and notes, and managing pipeline opportunities on behalf of each rep
🔐
Auth model
B2B SaaS — each sales rep connects their own Close account. identifier = your user ID
🔑
Close OAuth app
Register an OAuth app in Close at Settings → Developer → OAuth Apps. Copy the Client ID and Secret.
Setup
1 Setup: One SDK, One credential
Install the Scalekit SDK. The only credential your application manages is the Scalekit API key — no Close secrets, no user tokens, nothing belonging to your users.
pip install scalekit-sdk-python
npm install @scalekit-sdk/node
import scalekit.client
import os
from dotenv import load_dotenv
load_dotenv()
scalekit = scalekit.client.ScalekitClient(
client_id=os.getenv("SCALEKIT_CLIENT_ID"),
client_secret=os.getenv("SCALEKIT_CLIENT_SECRET"),
env_url=os.getenv("SCALEKIT_ENV_URL"),
)
actions = scalekit.actions
import { ScalekitClient } from '@scalekit-sdk/node';
import 'dotenv/config';
const scalekit = new ScalekitClient(
process.env.SCALEKIT_ENV_URL,
process.env.SCALEKIT_CLIENT_ID,
process.env.SCALEKIT_CLIENT_SECRET
);
const actions = scalekit.actions;
Already have credentials?
Connected Accounts
2 Per-User Auth: Creating connected accounts
Each sales rep gets their own Connected Account. The identifier is any unique string from your system — a UUID, email, or whatever you use internally.
response = actions.get_or_create_connected_account(
connection_name="close",
identifier="user_close_123" # your internal user ID
)
connected_account = response.connected_account
print(f"Status: {connected_account.status}")
# Status: PENDING — user hasn't authorized yet
const response = await actions.getOrCreateConnectedAccount({
connectionName: "close",
identifier: "user_close_123" // your internal user ID
});
const connectedAccount = response.connectedAccount;
console.log(`Status: ${connectedAccount.status}`);
// Status: PENDING — user hasn't authorized yet
This call is idempotent — safe to call on every session start. Returns the existing account if one already exists.
Authorization Flow
3 The authorization flow
The rep authorizes once. Scalekit generates the OAuth URL with the correct scopes and redirect handling pre-configured. After approval, you never see the token.
if connected_account.status != "ACTIVE":
link = actions.get_authorization_link(
connection_name="close",
identifier="user_close_123"
)
# Redirect user → Close's native OAuth consent screen
# Scalekit captures the token on callback
return redirect(link.link)
if (connectedAccount.status !== "ACTIVE") {
const { link } = await actions.getAuthorizationLink({
connectionName: "close",
identifier: "user_close_123"
});
// Redirect user → Close's native OAuth consent screen
// Scalekit captures the token on callback
return redirect(link);
}
Token management is automatic
After the user approves, Scalekit stores encrypted tokens and the connected account moves to ACTIVE. Close access tokens expire after 1 hour — Scalekit refreshes them automatically using the offline_access refresh token. If a user revokes access in Close, the account moves to REVOKED. No silent failures. Check account.status before critical operations.
Bring Your Own Credentials — required
Close requires you to register your own OAuth app and supply your Client ID and Secret. In the Scalekit dashboard, go to AgentKit → Connections → your Close connection and enter your credentials. Token management stays fully handled by Scalekit.
Calling Close
4 Calling Close: What your agent writes
With the connected account active, your agent calls Close actions using execute_tool(). Name the tool, pass parameters. Scalekit handles token retrieval and request construction.
Search and retrieve leads
Search leads by keyword or list all leads with sorting and pagination. Use query for full-text search — the same syntax as the Close search bar.
result = actions.execute_tool(
identifier="user_close_123",
tool_name="close_leads_list",
tool_input={
"query": "Acme Corp",
"_limit": 10,
"_order_by": "-date_updated"
}
)
# Returns: list of leads with id, name, status, contacts, and opportunities
const result = await actions.executeTool({
identifier: "user_close_123",
toolName: "close_leads_list",
toolInput: {
"query": "Acme Corp",
"_limit": 10,
"_order_by": "-date_updated"
}
});
// Returns: list of leads with id, name, status, contacts, and opportunities
Create a lead with an opportunity
Create a lead, then attach an opportunity to it. status_id must be a valid pipeline status ID — fetch available statuses with close_pipelines_list first. Monetary values are in cents.
# 1. Create the lead
lead = actions.execute_tool(
identifier="user_close_123",
tool_name="close_lead_create",
tool_input={
"name": "Acme Corp",
"description": "Inbound from demo request"
}
)
lead_id = lead["id"]
# 2. Get a pipeline status
pipelines = actions.execute_tool(
identifier="user_close_123",
tool_name="close_pipelines_list",
tool_input={}
)
active_status = next(
s for s in pipelines["data"][0]["statuses"]
if s["type"] == "active"
)
# 3. Create the opportunity
opportunity = actions.execute_tool(
identifier="user_close_123",
tool_name="close_opportunity_create",
tool_input={
"lead_id": lead_id,
"status_id": active_status["id"],
"value": 1200000, # $12,000 in cents
"value_currency": "USD",
"value_period": "annual",
"confidence": 40
}
)
// 1. Create the lead
const lead = await actions.executeTool({
identifier: "user_close_123",
toolName: "close_lead_create",
toolInput: {
"name": "Acme Corp",
"description": "Inbound from demo request"
}
});
const leadId = lead.id;
// 2. Get a pipeline status
const pipelines = await actions.executeTool({
identifier: "user_close_123",
toolName: "close_pipelines_list",
toolInput: {}
});
const activeStatus = pipelines.data[0].statuses.find(s => s.type === "active");
// 3. Create the opportunity
const opportunity = await actions.executeTool({
identifier: "user_close_123",
toolName: "close_opportunity_create",
toolInput: {
"lead_id": leadId,
"status_id": activeStatus.id,
"value": 1200000,
"value_currency": "USD",
"value_period": "annual",
"confidence": 40
}
});
Log a call and create a follow-up task
Record a completed call on a lead, then create a follow-up task. status on the call reflects the outcome — completed, no_answer, left_voicemail, etc.
from datetime import date, timedelta
# Log the call
actions.execute_tool(
identifier="user_close_123",
tool_name="close_call_create",
tool_input={
"lead_id": lead_id,
"status": "completed",
"direction": "outbound",
"duration": 840, # seconds
"note": "Discussed pricing. Needs legal review before signing."
}
)
# Schedule a follow-up task
tomorrow = (date.today() + timedelta(days=1)).isoformat()
actions.execute_tool(
identifier="user_close_123",
tool_name="close_task_create",
tool_input={
"lead_id": lead_id,
"text": "Follow up on legal review",
"date": tomorrow
}
)
// Log the call
await actions.executeTool({
identifier: "user_close_123",
toolName: "close_call_create",
toolInput: {
"lead_id": leadId,
"status": "completed",
"direction": "outbound",
"duration": 840,
"note": "Discussed pricing. Needs legal review before signing."
}
});
// Schedule a follow-up task
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
await actions.executeTool({
identifier: "user_close_123",
toolName: "close_task_create",
toolInput: {
"lead_id": leadId,
"text": "Follow up on legal review",
"date": tomorrow.toISOString().split("T")[0]
}
});
Enroll a contact in a sequence
Enroll a contact in an email sequence. Retrieve available sequences first with close_sequences_list. You can pause or resume enrollment with close_sequence_subscription_update.
# Get available sequences
sequences = actions.execute_tool(
identifier="user_close_123",
tool_name="close_sequences_list",
tool_input={"_limit": 5}
)
# Enroll the contact
subscription = actions.execute_tool(
identifier="user_close_123",
tool_name="close_sequence_subscription_create",
tool_input={
"contact_id": "cont_abc123",
"sequence_id": sequences["data"][0]["id"]
}
)
# Returns: subscription ID, status, and next send date
// Get available sequences
const sequences = await actions.executeTool({
identifier: "user_close_123",
toolName: "close_sequences_list",
toolInput: { "_limit": 5 }
});
// Enroll the contact
const subscription = await actions.executeTool({
identifier: "user_close_123",
toolName: "close_sequence_subscription_create",
toolInput: {
"contact_id": "cont_abc123",
"sequence_id": sequences.data[0].id
}
});
// Returns: subscription ID, status, and next send date
Framework wiring
5 Wiring into your agent framework
Scalekit integrates directly with LangChain. The agent decides what to look up or act on; Scalekit handles auth on every invocation. No token plumbing in your agent logic.
from langchain_anthropic import ChatAnthropic
from langchain.agents import AgentExecutor, create_tool_calling_agent
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from scalekit.langchain import get_tools
close_tools = get_tools(
connection_name="close",
identifier="user_close_123"
)
prompt = ChatPromptTemplate.from_messages([
("system", "You are a sales assistant. Use the available tools to help manage Close CRM — query leads, log activities, and update pipeline opportunities."),
MessagesPlaceholder("chat_history", optional=True),
("human", "{input}"),
MessagesPlaceholder("agent_scratchpad"),
])
agent = create_tool_calling_agent(ChatAnthropic(model="claude-sonnet-4-6"), close_tools, prompt)
result = AgentExecutor(agent=agent, tools=close_tools).invoke({
"input": "Find all open opportunities over $10k, summarize them, and create a follow-up task for each one due tomorrow"
})
import { ChatAnthropic } from "@langchain/anthropic";
import { AgentExecutor, createToolCallingAgent } from "langchain/agents";
import { ChatPromptTemplate, MessagesPlaceholder } from "@langchain/core/prompts";
import { getTools } from "@scalekit-sdk/langchain";
const closeTools = getTools({
connectionName: "close",
identifier: "user_close_123"
});
const prompt = ChatPromptTemplate.fromMessages([
["system", "You are a sales assistant. Use the available tools to help manage Close CRM — query leads, log activities, and update pipeline opportunities."],
new MessagesPlaceholder("chat_history", true),
["human", "{input}"],
new MessagesPlaceholder("agent_scratchpad"),
]);
const agent = await createToolCallingAgent({
llm: new ChatAnthropic({ model: "claude-sonnet-4-6" }),
tools: closeTools,
prompt
});
const result = await AgentExecutor.fromAgentAndTools({
agent,
tools: closeTools
}).invoke({
input: "Find all open opportunities over $10k, summarize them, and create a follow-up task for each one due tomorrow"
});
Other frameworks supported
Tool reference
All 81 Close tools
Grouped by object and capability. Your agent calls tools by name — no API wrappers to write.
Create a new lead with name, description, status, and website URL
Retrieve a single lead by ID with optional field selection
Update a lead's name, status, description, or website URL
Permanently delete a lead and all associated data
Merge two leads — source is merged into destination and deleted
List and full-text search leads with sorting and pagination
Create a contact on a lead with name, title, emails, and phone numbers
Retrieve a single contact by ID
Update a contact's name, title, email addresses, or phone numbers
Delete a contact from Close
List contacts, optionally filtered by lead ID
Create a deal with pipeline status, monetary value (in cents), currency, period, and confidence score
Retrieve a single opportunity by ID
Update status, value, expected close date, confidence, or note
Delete an opportunity from Close
List opportunities filtered by lead, user, status ID, or status type (active, won, lost)
Log an external call with direction, duration, status, and notes
Retrieve a single call activity by ID
Update a call's note, status, or duration
List calls filtered by lead, contact, or user
Log or send an email with subject, HTML/text body, sender, recipients, and status
Retrieve a single email activity by ID
Update an email's status, subject, or body
List email activities filtered by lead, contact, or user
Log or send an SMS with local/remote phone numbers, body text, and status
Retrieve a single SMS activity by ID
Update an SMS activity's text or status
List SMS activities filtered by lead, contact, or user
Create a plain-text note on a lead, optionally linked to a contact
Retrieve a single note by ID
Update the body text of a note
List notes filtered by lead, contact, or user
Post a comment on any Close object (lead, opportunity, etc.)
Retrieve a single comment by ID
Update the text of an existing comment
List comments on an object, filtered by object_id or thread_id
Create a task with due date, assigned user, and type — linked to a lead
Retrieve a single task by ID
Update task text, assignee, due date, or mark as complete
List tasks filtered by lead, assignee, type, or completion status. Supports inbox, future, and archive views
List email and activity sequences in the Close organization
Retrieve a single sequence by ID
close_sequence_subscription_create
Enroll a contact in a sequence, optionally specifying a sender account
close_sequence_subscription_get
Retrieve a single sequence subscription by ID
close_sequence_subscription_update
Pause or resume a contact's active sequence subscription
close_sequence_subscriptions_list
List sequence subscriptions filtered by lead, contact, or sequence ID
Create a new opportunity pipeline
Retrieve a single pipeline with its statuses by ID
Update a pipeline's name or statuses
List all pipelines in the organization including their statuses — use this to get valid status_id values
close_custom_field_lead_create
Create a custom field for leads (text, number, date, url, choices, etc.)
close_custom_field_lead_get
Retrieve a single lead custom field by ID
close_custom_field_lead_update
Update a lead custom field's name or choices
close_custom_field_lead_delete
Delete a lead custom field
close_custom_fields_lead_list
List all custom fields defined for leads
close_custom_field_contact_create
Create a custom field for contacts
close_custom_field_contact_get
Retrieve a single contact custom field by ID
close_custom_field_contact_update
Update a contact custom field's name or choices
close_custom_field_contact_delete
Delete a contact custom field
close_custom_fields_contact_list
List all custom fields defined for contacts
close_custom_field_opportunity_create
Create a custom field for opportunities
close_custom_field_opportunity_get
Retrieve a single opportunity custom field by ID
close_custom_field_opportunity_update
Update an opportunity custom field's name or choices
close_custom_field_opportunity_delete
Delete an opportunity custom field
close_custom_fields_opportunity_list
List all custom fields defined for opportunities
Retrieve the authenticated user's profile
List all users in the Close organization
Subscribe to Close events by URL and event type array
Retrieve a single webhook subscription by ID
Update a webhook's URL or event subscriptions
Delete a webhook subscription
List all webhook subscriptions in the account
List all activity types for a lead (calls, emails, notes, SMS, etc.) with optional type and user filter
Connector notes
Close-specific behavior
Opportunity values are in cents, not dollars
Close stores monetary values as integers in the smallest currency unit (cents for USD). A $12,000 deal is value: 1200000. Passing a dollar-denominated value silently creates an incorrectly small opportunity. Always convert before writing, and divide by 100 when displaying to users.
status_id is required to create opportunities — it is not a string label
Opportunity creation requires a status_id from a pipeline's statuses array — not a human-readable label like "In Progress". Call close_pipelines_list first to retrieve valid status IDs for the organization. Passing a freeform string returns a validation error.
Scopes are granted automatically — no configuration needed
Close OAuth apps automatically receive all.full_access and offline_access. You do not need to configure scopes in the Scalekit dashboard or request them explicitly. All 81 tools work with the default grant.
Infrastructure decision
Why not build this yourself
The Close OAuth flow is documented. Token storage isn't technically hard. But here's what you're actually signing up for:
PROBLEM 01
Access tokens expire every hour — proactive refresh logic per user, not reactive retries, is required to avoid surfacing errors to reps mid-workflow
PROBLEM 02
Per-user token isolation in a multi-tenant system — one rep's Close credentials must never be accessible to another, even within the same customer org
PROBLEM 03
Revocation detection without OAuth lifecycle hooks — when a user disconnects your app in Close, you need per-account status tracking to surface a re-authorization prompt rather than returning silent 401s
PROBLEM 04
Encrypted credential storage, connected account lifecycle management, and BYOC setup — all required before you write a single lead query
That's one connector. Your agent product will eventually need Salesforce, HubSpot, Zendesk, Gmail, and whatever else your customers ask for. Each has its own auth quirks and failure modes.
Scalekit maintains every connector. You maintain none of them.