Skip to content

Commit 586b97f

Browse files
authored
feat(attio): add attribute tools + fix API alignment gaps (#5445)
* feat(attio): add attribute tools + fix API alignment gaps - Add 4 new tools: attio_list_attributes, get_attribute, create_attribute, update_attribute - Add missing completed_at field to task tools - Fix note tags output to match Attio's actual response shape (workspace-member vs record tags) - Fix attribute outputs missing is_default_value_enabled, default_value, relationship - Fix create_comment sending both entry and thread_id (Attio requires exactly one) - Wire attribute pagination + task sort into the block * fix(attio): address review feedback on attribute tools - Throw on invalid config JSON instead of silently overwriting with {} (create_attribute, update_attribute) - create_attribute: omit config field entirely when not provided instead of always sending {} - Add "Leave unchanged" option to Required/Unique dropdowns so update_attribute no longer clears existing constraints on unrelated field updates - Fix stale sort param description on list_tasks (was missing completed_at:asc/desc variants) * fix(attio): fix silent update-clobber bugs + complete comment mutual-exclusion - create_comment: support all 3 of Attio's mutually-exclusive comment targets (thread_id, record, entry), not just thread_id/entry - Fix update_task silently clearing is_completed on unrelated field updates (taskIsCompletedUpdate now defaults to "leave unchanged") - Fix update_list silently resetting workspace_access to full-access on unrelated field updates (listWorkspaceAccessUpdate, same pattern) - create_attribute: restore config:{} as always-required per Attio's schema (previously omitted it entirely, which is invalid on create) - create_record/update_record/assert_record/list_records: throw on invalid values/filter/sorts JSON instead of silently substituting {} (was a silent data-loss risk on malformed input) - get_task: drop stray Content-Type header on a GET request - types.ts: remove two dead unreferenced interfaces, fix workspaceMemberAccess type (was string, is actually an array) * fix(attio): gate operation-specific param mapping on current operation Params like taskIsCompleted/listWorkspaceAccess/attributeIsMultiselect/ attributeIsArchived are only meant for one specific operation, but their mapping wasn't checking params.operation. A stale value persisted in block state from a prior operation selection (e.g. switching the dropdown from create_task to update_task) could leak through and override the "leave unchanged" default on the other operation. * fix(attio): explicit comment target selector + attribute archived tri-state - Add "Leave unchanged" default to attributeIsArchived dropdown, same pattern as isRequired/isUnique, so a stale archived flag from an earlier edit can't unarchive an attribute on an unrelated update - create_comment: replace field-presence inference (which let stale record fields hijack a list-entry comment or vice versa) with an explicit commentTarget selector (List Entry / Record / Reply to Thread). Only the fields for the selected target are ever forwarded * fix(attio): gate remaining unscoped param mappings + comment-target back-compat - taskFilterCompleted (list_tasks filter) was mapped to isCompleted for every operation; gate it to list_tasks so it can't override the isCompleted value on an unrelated update_task call - Generic threadId (get_thread) was unconditionally forwarded and create_comment prioritizes thread_id first, so a leftover threadId from configuring get_thread could silently hijack a comment meant for a list entry or record; gate it to get_thread - Default commentTarget to the pre-existing behavior (entry, or thread if commentThreadId is set) when absent, so blocks saved before this field existed keep working instead of throwing * fix(attio): gate every param mapping by its operation, eliminate stale-value class of bugs The params() function reuses cleanParams keys (title, content, apiSlug, filter, sorts, recordId, entryId, list, object, threadId, ...) across several unrelated operation families. Every mapping was unconditional on presence alone, so a stale value left in block state from a previously selected operation could silently leak into an unrelated request and overwrite the intended value (last-write-wins on a shared key). Rewrote the whole function to gate every line by params.operation against the exact operation set its subBlock's condition exposes it under, closing this entire bug class in one pass instead of patching individual instances as they were found in review. Also split attributeIsRequired/attributeIsUnique into create-only (default false) and update-only (tri-state, default "leave unchanged") variants — the shared field let an explicit Yes/No choice from create_attribute carry over and silently change constraints on an unrelated update_attribute call. * fix(attio): preserve legacy taskIsCompleted/listWorkspaceAccess on update taskIsCompleted and listWorkspaceAccess pre-date this PR as fields shared between create and update operations (confirmed present on origin/staging). Splitting them into create/update variants earlier this session meant existing saved blocks with a value stored under the legacy field name would silently stop applying it on update_task / update_list once the new -Update field (always undefined for old blocks) took over. Fall back to the legacy field when the new field is untouched, so old saved workflows keep behaving exactly as before.
1 parent a50b373 commit 586b97f

20 files changed

Lines changed: 1423 additions & 133 deletions

apps/sim/blocks/blocks/attio.ts

Lines changed: 648 additions & 74 deletions
Large diffs are not rendered by default.

apps/sim/tools/attio/assert_record.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,11 @@ export const attioAssertRecordTool: ToolConfig<AttioAssertRecordParams, AttioAss
5656
'Content-Type': 'application/json',
5757
}),
5858
body: (params) => {
59-
let values: Record<string, unknown> = {}
59+
let values: Record<string, unknown>
6060
try {
6161
values = typeof params.values === 'string' ? JSON.parse(params.values) : params.values
6262
} catch {
63-
values = {}
63+
throw new Error('Invalid JSON provided for record values')
6464
}
6565
return { data: { values } }
6666
},
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { createLogger } from '@sim/logger'
2+
import type { ToolConfig } from '@/tools/types'
3+
import type { AttioCreateAttributeParams, AttioCreateAttributeResponse } from './types'
4+
import { ATTRIBUTE_OUTPUT_PROPERTIES } from './types'
5+
6+
const logger = createLogger('AttioCreateAttribute')
7+
8+
export const attioCreateAttributeTool: ToolConfig<
9+
AttioCreateAttributeParams,
10+
AttioCreateAttributeResponse
11+
> = {
12+
id: 'attio_create_attribute',
13+
name: 'Attio Create Attribute',
14+
description: 'Create a new attribute (schema field) on an Attio object or list',
15+
version: '1.0.0',
16+
17+
oauth: {
18+
required: true,
19+
provider: 'attio',
20+
},
21+
22+
params: {
23+
accessToken: {
24+
type: 'string',
25+
required: true,
26+
visibility: 'hidden',
27+
description: 'The OAuth access token for the Attio API',
28+
},
29+
target: {
30+
type: 'string',
31+
required: true,
32+
visibility: 'user-or-llm',
33+
description: 'Whether to create the attribute on an object or a list: objects or lists',
34+
},
35+
identifier: {
36+
type: 'string',
37+
required: true,
38+
visibility: 'user-or-llm',
39+
description: 'The object or list ID or slug (e.g. people, companies)',
40+
},
41+
title: {
42+
type: 'string',
43+
required: true,
44+
visibility: 'user-or-llm',
45+
description: 'The attribute display title',
46+
},
47+
apiSlug: {
48+
type: 'string',
49+
required: true,
50+
visibility: 'user-or-llm',
51+
description: 'The attribute API slug (unique, snake_case)',
52+
},
53+
type: {
54+
type: 'string',
55+
required: true,
56+
visibility: 'user-or-llm',
57+
description:
58+
'The attribute value type (e.g. text, number, checkbox, currency, date, timestamp, rating, status, select, record-reference, actor-reference, location, domain, email-address, phone-number)',
59+
},
60+
description: {
61+
type: 'string',
62+
required: false,
63+
visibility: 'user-or-llm',
64+
description: 'A description of the attribute',
65+
},
66+
isRequired: {
67+
type: 'boolean',
68+
required: false,
69+
visibility: 'user-or-llm',
70+
description: 'Whether new records must provide a value (default false)',
71+
},
72+
isUnique: {
73+
type: 'boolean',
74+
required: false,
75+
visibility: 'user-or-llm',
76+
description: 'Whether the attribute enforces uniqueness on new data (default false)',
77+
},
78+
isMultiselect: {
79+
type: 'boolean',
80+
required: false,
81+
visibility: 'user-or-llm',
82+
description: 'Whether the attribute supports multiple values (default false)',
83+
},
84+
config: {
85+
type: 'string',
86+
required: false,
87+
visibility: 'user-or-llm',
88+
description:
89+
'JSON object of type-dependent configuration (e.g. currency or record-reference settings)',
90+
},
91+
},
92+
93+
request: {
94+
url: (params) =>
95+
`https://api.attio.com/v2/${params.target.trim()}/${params.identifier.trim()}/attributes`,
96+
method: 'POST',
97+
headers: (params) => ({
98+
Authorization: `Bearer ${params.accessToken}`,
99+
'Content-Type': 'application/json',
100+
}),
101+
body: (params) => {
102+
const data: Record<string, unknown> = {
103+
title: params.title,
104+
api_slug: params.apiSlug,
105+
description: params.description ?? null,
106+
type: params.type,
107+
is_required: params.isRequired ?? false,
108+
is_unique: params.isUnique ?? false,
109+
is_multiselect: params.isMultiselect ?? false,
110+
// `config` is a required key on Attio's create-attribute request body (even though its
111+
// nested fields are only required for type-dependent configs like currency/record-reference).
112+
config: {},
113+
}
114+
if (params.config) {
115+
try {
116+
data.config =
117+
typeof params.config === 'string' ? JSON.parse(params.config) : params.config
118+
} catch {
119+
throw new Error('Invalid JSON provided for attribute config')
120+
}
121+
}
122+
return { data }
123+
},
124+
},
125+
126+
transformResponse: async (response) => {
127+
const data = await response.json()
128+
if (!response.ok) {
129+
logger.error('Attio API request failed', { data, status: response.status })
130+
throw new Error(data.message || 'Failed to create attribute')
131+
}
132+
const attr = data.data
133+
return {
134+
success: true,
135+
output: {
136+
attributeId: attr.id?.attribute_id ?? null,
137+
title: attr.title ?? null,
138+
apiSlug: attr.api_slug ?? null,
139+
description: attr.description ?? null,
140+
type: attr.type ?? null,
141+
isSystemAttribute: attr.is_system_attribute ?? false,
142+
isWritable: attr.is_writable ?? false,
143+
isRequired: attr.is_required ?? false,
144+
isUnique: attr.is_unique ?? false,
145+
isMultiselect: attr.is_multiselect ?? false,
146+
isDefaultValueEnabled: attr.is_default_value_enabled ?? false,
147+
isArchived: attr.is_archived ?? false,
148+
defaultValue: attr.default_value ?? null,
149+
relationship: attr.relationship ?? null,
150+
config: attr.config ?? null,
151+
createdAt: attr.created_at ?? null,
152+
},
153+
}
154+
},
155+
156+
outputs: ATTRIBUTE_OUTPUT_PROPERTIES,
157+
}

apps/sim/tools/attio/create_comment.ts

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,21 +52,37 @@ export const attioCreateCommentTool: ToolConfig<
5252
},
5353
list: {
5454
type: 'string',
55-
required: true,
55+
required: false,
5656
visibility: 'user-or-llm',
57-
description: 'The list ID or slug the entry belongs to',
57+
description:
58+
'The list ID or slug the entry belongs to (used with entryId; omit if threadId or recordId is set)',
5859
},
5960
entryId: {
6061
type: 'string',
61-
required: true,
62+
required: false,
63+
visibility: 'user-or-llm',
64+
description:
65+
'The list entry ID to comment on (used with list; omit if threadId or recordId is set)',
66+
},
67+
recordObject: {
68+
type: 'string',
69+
required: false,
6270
visibility: 'user-or-llm',
63-
description: 'The entry ID to comment on',
71+
description:
72+
'The object ID or slug the record belongs to (used with recordId; omit if threadId or entryId is set)',
73+
},
74+
recordId: {
75+
type: 'string',
76+
required: false,
77+
visibility: 'user-or-llm',
78+
description:
79+
'The record ID to comment on directly (used with recordObject; omit if threadId or entryId is set)',
6480
},
6581
threadId: {
6682
type: 'string',
6783
required: false,
6884
visibility: 'user-or-llm',
69-
description: 'Thread ID to reply to (omit to start a new thread)',
85+
description: 'Thread ID to reply to (omit to start a new thread on a record or list entry)',
7086
},
7187
createdAt: {
7288
type: 'string',
@@ -91,12 +107,25 @@ export const attioCreateCommentTool: ToolConfig<
91107
type: params.authorType,
92108
id: params.authorId,
93109
},
94-
entry: {
110+
}
111+
// Attio's comment body accepts exactly one of `thread_id`, `record`, or `entry` — mutually exclusive.
112+
if (params.threadId) {
113+
data.thread_id = params.threadId
114+
} else if (params.recordObject && params.recordId) {
115+
data.record = {
116+
object: params.recordObject,
117+
record_id: params.recordId,
118+
}
119+
} else if (params.list && params.entryId) {
120+
data.entry = {
95121
list: params.list,
96122
entry_id: params.entryId,
97-
},
123+
}
124+
} else {
125+
throw new Error(
126+
'Must provide either threadId, both recordObject and recordId, or both list and entryId'
127+
)
98128
}
99-
if (params.threadId) data.thread_id = params.threadId
100129
if (params.createdAt) data.created_at = params.createdAt
101130
return { data }
102131
},

apps/sim/tools/attio/create_note.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createLogger } from '@sim/logger'
22
import type { ToolConfig } from '@/tools/types'
33
import type { AttioCreateNoteParams, AttioCreateNoteResponse } from './types'
4-
import { NOTE_OUTPUT_PROPERTIES } from './types'
4+
import { mapNoteTags, NOTE_OUTPUT_PROPERTIES } from './types'
55

66
const logger = createLogger('AttioCreateNote')
77

@@ -105,7 +105,7 @@ export const attioCreateNoteTool: ToolConfig<AttioCreateNoteParams, AttioCreateN
105105
contentPlaintext: note.content_plaintext ?? null,
106106
contentMarkdown: note.content_markdown ?? null,
107107
meetingId: note.meeting_id ?? null,
108-
tags: note.tags ?? [],
108+
tags: mapNoteTags(note.tags),
109109
createdByActor: note.created_by_actor ?? null,
110110
createdAt: note.created_at ?? null,
111111
},

apps/sim/tools/attio/create_record.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export const attioCreateRecordTool: ToolConfig<AttioCreateRecordParams, AttioCre
5050
try {
5151
values = typeof params.values === 'string' ? JSON.parse(params.values) : params.values
5252
} catch {
53-
values = {}
53+
throw new Error('Invalid JSON provided for record values')
5454
}
5555
return { data: { values } }
5656
},

apps/sim/tools/attio/create_task.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ export const attioCreateTaskTool: ToolConfig<AttioCreateTaskParams, AttioCreateT
124124
content: task.content_plaintext ?? null,
125125
deadlineAt: task.deadline_at ?? null,
126126
isCompleted: task.is_completed ?? false,
127+
completedAt: task.completed_at ?? null,
127128
linkedRecords,
128129
assignees,
129130
createdByActor: task.created_by_actor ?? null,
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { createLogger } from '@sim/logger'
2+
import type { ToolConfig } from '@/tools/types'
3+
import type { AttioGetAttributeParams, AttioGetAttributeResponse } from './types'
4+
import { ATTRIBUTE_OUTPUT_PROPERTIES } from './types'
5+
6+
const logger = createLogger('AttioGetAttribute')
7+
8+
export const attioGetAttributeTool: ToolConfig<AttioGetAttributeParams, AttioGetAttributeResponse> =
9+
{
10+
id: 'attio_get_attribute',
11+
name: 'Attio Get Attribute',
12+
description: 'Get a single attribute (schema field) on an Attio object or list',
13+
version: '1.0.0',
14+
15+
oauth: {
16+
required: true,
17+
provider: 'attio',
18+
},
19+
20+
params: {
21+
accessToken: {
22+
type: 'string',
23+
required: true,
24+
visibility: 'hidden',
25+
description: 'The OAuth access token for the Attio API',
26+
},
27+
target: {
28+
type: 'string',
29+
required: true,
30+
visibility: 'user-or-llm',
31+
description: 'Whether the attribute belongs to an object or a list: objects or lists',
32+
},
33+
identifier: {
34+
type: 'string',
35+
required: true,
36+
visibility: 'user-or-llm',
37+
description: 'The object or list ID or slug (e.g. people, companies)',
38+
},
39+
attribute: {
40+
type: 'string',
41+
required: true,
42+
visibility: 'user-or-llm',
43+
description: 'The attribute ID or slug',
44+
},
45+
},
46+
47+
request: {
48+
url: (params) =>
49+
`https://api.attio.com/v2/${params.target.trim()}/${params.identifier.trim()}/attributes/${params.attribute.trim()}`,
50+
method: 'GET',
51+
headers: (params) => ({
52+
Authorization: `Bearer ${params.accessToken}`,
53+
}),
54+
},
55+
56+
transformResponse: async (response) => {
57+
const data = await response.json()
58+
if (!response.ok) {
59+
logger.error('Attio API request failed', { data, status: response.status })
60+
throw new Error(data.message || 'Failed to get attribute')
61+
}
62+
const attr = data.data
63+
return {
64+
success: true,
65+
output: {
66+
attributeId: attr.id?.attribute_id ?? null,
67+
title: attr.title ?? null,
68+
apiSlug: attr.api_slug ?? null,
69+
description: attr.description ?? null,
70+
type: attr.type ?? null,
71+
isSystemAttribute: attr.is_system_attribute ?? false,
72+
isWritable: attr.is_writable ?? false,
73+
isRequired: attr.is_required ?? false,
74+
isUnique: attr.is_unique ?? false,
75+
isMultiselect: attr.is_multiselect ?? false,
76+
isDefaultValueEnabled: attr.is_default_value_enabled ?? false,
77+
isArchived: attr.is_archived ?? false,
78+
defaultValue: attr.default_value ?? null,
79+
relationship: attr.relationship ?? null,
80+
config: attr.config ?? null,
81+
createdAt: attr.created_at ?? null,
82+
},
83+
}
84+
},
85+
86+
outputs: ATTRIBUTE_OUTPUT_PROPERTIES,
87+
}

apps/sim/tools/attio/get_note.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createLogger } from '@sim/logger'
22
import type { ToolConfig } from '@/tools/types'
33
import type { AttioGetNoteParams, AttioGetNoteResponse } from './types'
4-
import { NOTE_OUTPUT_PROPERTIES } from './types'
4+
import { mapNoteTags, NOTE_OUTPUT_PROPERTIES } from './types'
55

66
const logger = createLogger('AttioGetNote')
77

@@ -56,7 +56,7 @@ export const attioGetNoteTool: ToolConfig<AttioGetNoteParams, AttioGetNoteRespon
5656
contentPlaintext: note.content_plaintext ?? null,
5757
contentMarkdown: note.content_markdown ?? null,
5858
meetingId: note.meeting_id ?? null,
59-
tags: note.tags ?? [],
59+
tags: mapNoteTags(note.tags),
6060
createdByActor: note.created_by_actor ?? null,
6161
createdAt: note.created_at ?? null,
6262
},

0 commit comments

Comments
 (0)