The real problem
Why this is harder than it looks
Attio's REST API is well-designed and the documentation is thorough. Most developers expect to spend an afternoon wiring up a connection. The complexity arrives when you move from a single-workspace prototype to a multi-tenant product with real users.
The first thing to understand is that Attio requires you to register your own OAuth app at build.attio.com — there is no shared managed app you can borrow for getting started. You supply your Client ID and Client Secret, configure the redirect URI exactly as registered, and own the full token lifecycle from day one. That means per-user token storage that is securely isolated across tenants, proactive refresh logic before access tokens expire, and revocation detection when a user disconnects your app from their Attio workspace settings.
Then there is Attio's scope model. Attio exposes a fine-grained set of OAuth scopes — separate scopes for reading vs. writing records, notes, tasks, comments, lists, and workspace configuration. Requesting too broad a scope triggers friction on the consent screen for security-conscious teams; requesting too narrow a scope causes silent API failures at runtime when your agent tries to write a note or create a task. Getting scope selection right requires understanding Attio's data model upfront, and any capability expansion later requires re-authorization of existing users.
Finally, Attio's data model is meaningfully different from conventional CRMs. Attribute values are typed arrays — even single-value fields like a company name must be passed as an array. Filter syntax varies per attribute type, and UUIDs are used everywhere instead of slugs. An agent that hardcodes field names or assumes flat record structures will fail silently across different customer workspaces. Scalekit handles the auth plumbing — token storage, refresh, revocation detection, per-user isolation — so your agent code can focus on the data model, not the auth model.
Capabilities
What your agent can do with Attio
Once connected, your agent has 50 pre-built tools covering Attio's full CRM object model:
- Manage people, companies, and deals: create, retrieve, list, filter, and delete records across all standard CRM objects
- Work with tasks and notes: create tasks with deadlines and assignees, add notes in plaintext or Markdown, link both to any CRM record
- Search and filter records: fuzzy text search across objects or precise attribute-based filtering with full sort and pagination support
- Manage lists and entries: add or remove records from Attio lists, retrieve list-level attribute values, track pipeline stage per entry
- Inspect workspace schema: discover objects, attributes, select options, and status values at runtime — essential for agents working across diverse customer workspaces
Setup context
What we're building
This guide connects a CRM assistant agent to Attio — helping sales and operations teams manage contacts, companies, deals, tasks, and notes without leaving your product.
🤖
Example agent
CRM assistant creating records, logging notes, and managing tasks on behalf of each sales rep
🔐
Auth model
B2B SaaS — each rep connects their own Attio workspace. identifier = your user ID
Setup
1 Setup: One SDK, One credential
Install the Scalekit SDK. The only credential your application manages is the Scalekit API key — no Attio secrets, no user tokens, nothing belonging to your customers.
pip install scalekit-sdk-python
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 rep gets their own Connected Account, giving them a dedicated auth context. The identifier is any unique string from your system — a UUID, email, whatever you use internally.
response = actions.get_or_create_connected_account(
connection_name="attio",
identifier="user_attio_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: "attio",
identifier: "user_attio_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 your agent once. Scalekit generates the OAuth URL with correct scopes, PKCE challenge, and redirect handling pre-configured. After approval, you never see the token.
if connected_account.status != "ACTIVE":
link = actions.get_authorization_link(
connection_name="attio",
identifier="user_attio_123"
)
# Redirect user → Attio OAuth consent screen
# Scalekit captures the token on callback
return redirect(link.link)
if (connectedAccount.status !== "ACTIVE") {
const { link } = await actions.getAuthorizationLink({
connectionName: "attio",
identifier: "user_attio_123"
});
// Redirect user → Attio 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. Access tokens refresh before expiry. If a rep revokes access from their Attio workspace settings, the account moves to REVOKED — no silent failures. Check account.status before critical operations.
BYOC is required for Attio
Attio does not provide a managed shared app. You must register your own OAuth app at build.attio.com, copy the Scalekit redirect URI into your app's OAuth settings, configure the required scopes, then paste your Client ID and Client Secret into the Scalekit dashboard. Token management is fully handled after that one-time setup.
Calling Attio
4 Calling Attio: What your agent writes
With the connected account active, your agent calls Attio actions using execute_tool. Name the tool, pass parameters. Scalekit handles token retrieval, request construction, and response parsing.
Create a company
Attio attribute values are typed arrays — even single-value fields like name must be passed as an array. Use attio_list_attributes to discover the expected structure for any object before writing.
result = actions.execute_tool(
identifier="user_attio_123",
tool_name="attio_create_company",
tool_input={
"values": {
"name": [{"value": "Acme Corp"}],
"domains": [{"domain": "acme.com"}],
"description": [{"value": "Enterprise SaaS company"}]
}
}
)
# Returns: { "id": { "record_id": "..." }, "values": { ... } }
const result = await actions.executeTool({
identifier: "user_attio_123",
toolName: "attio_create_company",
toolInput: {
values: {
name: [{ value: "Acme Corp" }],
domains: [{ domain: "acme.com" }],
description: [{ value: "Enterprise SaaS company" }]
}
}
});
// Returns: { id: { record_id: "..." }, values: { ... } }
Create a person and link to a company
Pass the company's record_id from the previous step to associate the person. Multi-value attributes like email_addresses require the full typed structure.
result = actions.execute_tool(
identifier="user_attio_123",
tool_name="attio_create_person",
tool_input={
"values": {
"name": [{"first_name": "Jane", "last_name": "Smith"}],
"email_addresses": [{
"email_address": "jane@acme.com",
"attribute_type": "email"
}],
"company": [{"target_record_id": ""}]
}
}
)
const result = await actions.executeTool({
identifier: "user_attio_123",
toolName: "attio_create_person",
toolInput: {
values: {
name: [{ first_name: "Jane", last_name: "Smith" }],
email_addresses: [{
email_address: "jane@acme.com",
attribute_type: "email"
}],
company: [{ target_record_id: "" }]
}
}
});
List and filter records
Use attio_list_records for precise attribute-based filtering. Filter syntax varies per attribute type — use attio_list_attributes first to confirm slugs and expected value structure.
result = actions.execute_tool(
identifier="user_attio_123",
tool_name="attio_list_records",
tool_input={
"object": "people",
"filter": {
"email_addresses": [{
"email_address": {"$eq": "jane@acme.com"}
}]
},
"limit": 10
}
)
const result = await actions.executeTool({
identifier: "user_attio_123",
toolName: "attio_list_records",
toolInput: {
object: "people",
filter: {
email_addresses: [{
email_address: { "$eq": "jane@acme.com" }
}]
},
limit: 10
}
});
Create a task linked to a record
Tasks can be linked to any CRM record and assigned to workspace members. Use attio_list_workspace_members to resolve member UUIDs before assigning.
from datetime import datetime, timedelta, timezone
deadline = (datetime.now(timezone.utc) + timedelta(days=7)).strftime("%Y-%m-%dT%H:%M:%S.000Z")
result = actions.execute_tool(
identifier="user_attio_123",
tool_name="attio_create_task",
tool_input={
"content": "Send Q2 pricing proposal to Jane",
"deadline_at": deadline,
"is_completed": False,
"linked_records": [{
"target_object": "people",
"target_record_id": "
"
}]
}
)
# Returns: { "id": { "task_id": "..." }, "content": "...", ... }const deadline = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
const result = await actions.executeTool({
identifier: "user_attio_123",
toolName: "attio_create_task",
toolInput: {
content: "Send Q2 pricing proposal to Jane",
deadline_at: deadline,
is_completed: false,
linked_records: [{
target_object: "people",
target_record_id: "
"
}]
}
});
// Returns: { id: { task_id: "..." }, content: "...", ... }Framework wiring
5 Wiring into your agent framework
Scalekit integrates directly with LangChain. The agent decides what to call; 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
attio_tools = get_tools(
connection_name="attio",
identifier="user_attio_123"
)
prompt = ChatPromptTemplate.from_messages([
("system", "You are a CRM assistant. Use the available tools to help manage Attio contacts, companies, deals, tasks, and notes."),
MessagesPlaceholder("chat_history", optional=True),
("human", "{input}"),
MessagesPlaceholder("agent_scratchpad"),
])
agent = create_tool_calling_agent(ChatAnthropic(model="claude-sonnet-4-6"), attio_tools, prompt)
result = AgentExecutor(agent=agent, tools=attio_tools).invoke({
"input": "Find Jane Smith's contact record, log a note about today's call, and create a follow-up task for next week"
})
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 attioTools = getTools({
connectionName: "attio",
identifier: "user_attio_123"
});
const prompt = ChatPromptTemplate.fromMessages([
["system", "You are a CRM assistant. Use the available tools to help manage Attio contacts, companies, deals, tasks, and notes."],
new MessagesPlaceholder("chat_history", true),
["human", "{input}"],
new MessagesPlaceholder("agent_scratchpad"),
]);
const agent = await createToolCallingAgent({
llm: new ChatAnthropic({ model: "claude-sonnet-4-6" }),
tools: attioTools,
prompt
});
const result = await AgentExecutor.fromAgentAndTools({
agent,
tools: attioTools
}).invoke({
input: "Find Jane Smith's contact record, log a note about today's call, and create a follow-up task for next week"
});
Other frameworks supported
Tool reference
All 50 Attio tools
Grouped by object and capability. Your agent calls tools by name — no API wrappers to write.
Create a person record with name, email, phone, and company association. Throws on unique attribute conflicts
List people with attribute-based filtering, sorting, and pagination
Retrieve a single person record by UUID with full attribute and audit metadata
Permanently delete a person record by UUID. Irreversible
Create a company with name, domain, description, and custom attributes. Throws if domain already exists
List companies with attribute-based filtering, sorting, and pagination
Retrieve a single company by UUID with full attribute and audit metadata
Permanently delete a company record by UUID. Irreversible
Create a deal with name, value (currency), stage, and company association
List deals with attribute-based filtering, sorting, and pagination
Retrieve a single deal by UUID with full attribute and audit metadata
Permanently delete a deal record by UUID. Irreversible
Create a record for any object type — standard or custom. Use when the object type is determined at runtime
List records for any object with precise attribute-based filtering. Prefer over search for exact matches
Fuzzy text search across any object by name, email, or domain. Use for user-facing search flows
Retrieve a record by object type and UUID with full audit trail per attribute value
Permanently delete any record by object type and UUID. Irreversible
attio_get_record_attribute_values
Retrieve all values (including history) for a specific attribute on a record
Create a task with content, deadline, assignees, and links to one or more CRM records
List tasks filtered by linked record, completion status, with pagination
Retrieve a single task by UUID with content, deadline, assignees, and linked records
Permanently delete a task by UUID. Irreversible
Create a note on any record in plaintext or Markdown. Supports backdating and meeting association
List notes for a specific record with pagination (max 50 per page)
Retrieve a note by UUID with title, content in both plaintext and Markdown, and creator info
Permanently delete a note by UUID. Irreversible
Create a comment on a record or list entry. Supports threading via thread_id
Retrieve a comment by UUID with content, author, thread ID, and resolution status
Delete a comment. Deleting a thread head also removes all replies
List all comment threads on a record or list entry with messages and resolution status
Retrieve all CRM lists in the workspace — pipelines, outreach targets, and custom groupings
Create a new CRM list with name, slug, and workspace access configuration
Retrieve list details by UUID or slug including access configuration
List entries in a CRM list with filtering, sorting, and pagination. Returns list-level attribute values per entry
Retrieve a single list entry by entry UUID with list-specific attribute values
Add a record to a list. Optionally set list-level attributes (e.g., pipeline stage) on the entry
Remove a specific entry from a list by entry UUID. Does not delete the underlying record
attio_list_record_entries
List all list memberships for a record across all lists — returns list IDs and entry IDs for removal
List all objects in the workspace — system objects and custom objects. Call first to discover available types
Retrieve details of a single object by slug or UUID
Create a new custom object type with singular/plural names and API slug
List attribute schema for any object or list — slugs, types, and config. Call before filtering or writing
Retrieve full attribute details including type, config, required/unique flags, and metadata
Add a new attribute to any object or list. Supports 15 types including text, select, status, currency, and record-reference
attio_list_attribute_options
List valid options for a select or multiselect attribute before writing
attio_list_attribute_statuses
List valid statuses for a status attribute before writing
attio_list_workspace_members
List workspace members to resolve UUIDs for task assignment and actor-reference attributes
attio_get_workspace_member
Retrieve a member by UUID with name, email, and access level
List end-user records (distinct from workspace members) with filtering and pagination
Permanently delete a user record by UUID
attio_list_workspace_records
List workspace records (connected SaaS product instances) with filtering and pagination
attio_get_workspace_record
Retrieve a workspace record by UUID with full attribute and audit metadata
attio_delete_workspace_record
Permanently delete a workspace record by UUID
Retrieve all webhooks with event subscriptions and statuses
Retrieve a single webhook by UUID with target URL and subscribed event types
Permanently delete a webhook by UUID
List meetings in the workspace with optional participant and record filters (beta)
attio_get_current_token_info
Verify the active token, connected workspace, authenticated actor, and granted OAuth scopes
Connector notes
Attio-specific behavior
Attribute values are always arrays
Attio's data model requires attribute values to be typed arrays — even single-value fields like company name must be passed as an array with one item. Multi-value attributes like email_addresses follow the same pattern. The structure varies per attribute type. Always call attio_list_attributes on the target object before writing to confirm the correct value format.
IDs are UUIDs — resolve slugs at runtime
Attio uses UUIDs for records, attributes, workspace members, lists, and entries — not human-readable slugs. Use attio_list_objects, attio_list_attributes, and attio_list_workspace_members to resolve the UUIDs your agent needs before creating or updating records. An agent that hardcodes IDs from one workspace will break silently in another.
Verify scopes before write operations
Use attio_get_current_token_info before performing write operations to confirm the required scope is present. Attio enforces fine-grained scopes per resource type — a token with record_permission:read cannot create notes or tasks. A missing scope returns a clear API error, but checking proactively prevents mid-workflow failures.
Infrastructure decision
Why not build this yourself
The Attio OAuth flow is documented. Token storage isn't technically hard. But here's what you're actually signing up for:
PROBLEM 01
BYOC with no managed app fallback — you register and maintain your own Attio OAuth app, including redirect URI configuration and credential rotation
PROBLEM 02
Fine-grained scope selection across 12+ resource-level scopes — under-scoping causes runtime failures, over-scoping causes enterprise security teams to reject the consent screen
PROBLEM 03
Per-user token isolation across a multi-tenant system — one rep's Attio workspace credentials must never be accessible to another
PROBLEM 04
Revocation detection when users disconnect your app from Attio — and proactive refresh before token expiry so agents don't fail mid-operation
That's one connector. Your agent product will eventually need Salesforce, HubSpot, Gmail, Linear, and whatever else your customers ask for. Each has its own OAuth quirks and failure modes.
Scalekit maintains every connector. You maintain none of them.