From 04f1d015f35ea90da4aaab4925b5e099fd4fe4f3 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 24 Apr 2026 11:36:50 -0700 Subject: [PATCH 1/9] fix(mothership): Use heartbeat mechanism for chat locks (#4286) --- .../lib/copilot/request/lifecycle/start.ts | 1 + .../lib/copilot/request/session/abort.test.ts | 120 ++++++++++++++++++ apps/sim/lib/copilot/request/session/abort.ts | 52 +++++++- apps/sim/lib/core/config/redis.test.ts | 43 +++++++ apps/sim/lib/core/config/redis.ts | 38 ++++++ .../testing/src/mocks/redis-config.mock.ts | 2 + packages/testing/src/mocks/redis.mock.ts | 3 + 7 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 apps/sim/lib/copilot/request/session/abort.test.ts diff --git a/apps/sim/lib/copilot/request/lifecycle/start.ts b/apps/sim/lib/copilot/request/lifecycle/start.ts index ab16f332899..37d58624c17 100644 --- a/apps/sim/lib/copilot/request/lifecycle/start.ts +++ b/apps/sim/lib/copilot/request/lifecycle/start.ts @@ -210,6 +210,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS const abortPoller = startAbortPoller(streamId, abortController, { requestId, + chatId, }) publisher.startKeepalive() diff --git a/apps/sim/lib/copilot/request/session/abort.test.ts b/apps/sim/lib/copilot/request/session/abort.test.ts new file mode 100644 index 00000000000..9c6ee82aa08 --- /dev/null +++ b/apps/sim/lib/copilot/request/session/abort.test.ts @@ -0,0 +1,120 @@ +/** + * @vitest-environment node + */ + +import { redisConfigMock, redisConfigMockFns } from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockHasAbortMarker, mockClearAbortMarker, mockWriteAbortMarker } = vi.hoisted(() => ({ + mockHasAbortMarker: vi.fn().mockResolvedValue(false), + mockClearAbortMarker: vi.fn().mockResolvedValue(undefined), + mockWriteAbortMarker: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('@/lib/core/config/redis', () => redisConfigMock) +vi.mock('@/lib/copilot/request/session/buffer', () => ({ + hasAbortMarker: mockHasAbortMarker, + clearAbortMarker: mockClearAbortMarker, + writeAbortMarker: mockWriteAbortMarker, +})) +vi.mock('@/lib/copilot/request/otel', () => ({ + withCopilotSpan: (_span: unknown, _attrs: unknown, fn: (span: unknown) => unknown) => + fn({ setAttribute: vi.fn() }), +})) + +import { startAbortPoller } from '@/lib/copilot/request/session/abort' + +describe('startAbortPoller heartbeat', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + mockHasAbortMarker.mockResolvedValue(false) + redisConfigMockFns.mockExtendLock.mockResolvedValue(true) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('extends the chat stream lock approximately every heartbeat interval', async () => { + const controller = new AbortController() + const streamId = 'stream-heartbeat-1' + const chatId = 'chat-heartbeat-1' + + const interval = startAbortPoller(streamId, controller, { chatId }) + + try { + await vi.advanceTimersByTimeAsync(15_000) + expect(redisConfigMockFns.mockExtendLock).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(6_000) + + expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1) + expect(redisConfigMockFns.mockExtendLock).toHaveBeenLastCalledWith( + `copilot:chat-stream-lock:${chatId}`, + streamId, + 60 + ) + + await vi.advanceTimersByTimeAsync(20_000) + expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(2) + + await vi.advanceTimersByTimeAsync(20_000) + expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(3) + } finally { + clearInterval(interval) + } + }) + + it('does not extend the lock when no chatId is passed (backward compat)', async () => { + const controller = new AbortController() + const interval = startAbortPoller('stream-no-chat', controller, {}) + + try { + await vi.advanceTimersByTimeAsync(90_000) + expect(redisConfigMockFns.mockExtendLock).not.toHaveBeenCalled() + } finally { + clearInterval(interval) + } + }) + + it('retries on the next tick when extendLock throws (no 20s backoff)', async () => { + const controller = new AbortController() + const streamId = 'stream-retry' + const chatId = 'chat-retry' + + redisConfigMockFns.mockExtendLock.mockRejectedValueOnce(new Error('redis down')) + + const interval = startAbortPoller(streamId, controller, { chatId }) + + try { + await vi.advanceTimersByTimeAsync(20_000) + expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(1_000) + expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(2) + } finally { + clearInterval(interval) + } + }) + + it('stops heartbeating after ownership is lost', async () => { + const controller = new AbortController() + const streamId = 'stream-lost' + const chatId = 'chat-lost' + + redisConfigMockFns.mockExtendLock.mockResolvedValueOnce(false) + + const interval = startAbortPoller(streamId, controller, { chatId }) + + try { + await vi.advanceTimersByTimeAsync(21_000) + expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(60_000) + expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1) + } finally { + clearInterval(interval) + } + }) +}) diff --git a/apps/sim/lib/copilot/request/session/abort.ts b/apps/sim/lib/copilot/request/session/abort.ts index e094346adc9..db3beff57ea 100644 --- a/apps/sim/lib/copilot/request/session/abort.ts +++ b/apps/sim/lib/copilot/request/session/abort.ts @@ -5,7 +5,7 @@ import { AbortBackend } from '@/lib/copilot/generated/trace-attribute-values-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { withCopilotSpan } from '@/lib/copilot/request/otel' -import { acquireLock, getRedisClient, releaseLock } from '@/lib/core/config/redis' +import { acquireLock, extendLock, getRedisClient, releaseLock } from '@/lib/core/config/redis' import { AbortReason } from './abort-reason' import { clearAbortMarker, hasAbortMarker, writeAbortMarker } from './buffer' @@ -18,7 +18,22 @@ const pendingChatStreams = new Map< >() const DEFAULT_ABORT_POLL_MS = 1000 -const CHAT_STREAM_LOCK_TTL_SECONDS = 2 * 60 * 60 + +/** + * TTL for the per-chat stream lock. Kept short so that if the Sim pod + * holding the lock dies (SIGKILL, OOM, a SIGTERM drain that doesn't + * reach the release path), the lock self-heals inside a minute rather + * than stranding the chat for hours. A live stream keeps the lock alive + * via `CHAT_STREAM_LOCK_HEARTBEAT_INTERVAL_MS` heartbeats. + */ +const CHAT_STREAM_LOCK_TTL_SECONDS = 60 + +/** + * Heartbeat cadence for extending the per-chat stream lock. Set to a + * third of the TTL so one missed beat still leaves room for recovery + * before the lock expires under a still-live stream. + */ +const CHAT_STREAM_LOCK_HEARTBEAT_INTERVAL_MS = 20_000 function registerPendingChatStream(chatId: string, streamId: string): void { let resolve!: () => void @@ -262,10 +277,14 @@ const pollingStreams = new Set() export function startAbortPoller( streamId: string, abortController: AbortController, - options?: { pollMs?: number; requestId?: string } + options?: { pollMs?: number; requestId?: string; chatId?: string } ): ReturnType { const pollMs = options?.pollMs ?? DEFAULT_ABORT_POLL_MS const requestId = options?.requestId + const chatId = options?.chatId + + let lastHeartbeatAt = Date.now() + let heartbeatOwnershipLost = false return setInterval(() => { if (pollingStreams.has(streamId)) return @@ -287,6 +306,33 @@ export function startAbortPoller( } finally { pollingStreams.delete(streamId) } + + if (!chatId || heartbeatOwnershipLost) return + if (Date.now() - lastHeartbeatAt < CHAT_STREAM_LOCK_HEARTBEAT_INTERVAL_MS) return + + try { + const owned = await extendLock( + getChatStreamLockKey(chatId), + streamId, + CHAT_STREAM_LOCK_TTL_SECONDS + ) + lastHeartbeatAt = Date.now() + if (!owned) { + heartbeatOwnershipLost = true + logger.warn('Lost ownership of chat stream lock — stopping heartbeat', { + chatId, + streamId, + ...(requestId ? { requestId } : {}), + }) + } + } catch (error) { + logger.warn('Failed to extend chat stream lock TTL', { + chatId, + streamId, + ...(requestId ? { requestId } : {}), + error: toError(error).message, + }) + } })() }, pollMs) } diff --git a/apps/sim/lib/core/config/redis.test.ts b/apps/sim/lib/core/config/redis.test.ts index cad0051753b..b41ddb3da5b 100644 --- a/apps/sim/lib/core/config/redis.test.ts +++ b/apps/sim/lib/core/config/redis.test.ts @@ -15,6 +15,7 @@ vi.mock('ioredis', () => ({ import { closeRedisConnection, + extendLock, getRedisClient, onRedisReconnect, resetForTesting, @@ -120,6 +121,48 @@ describe('redis config', () => { }) }) + describe('extendLock', () => { + const lockKey = 'copilot:chat-stream-lock:chat-1' + const value = 'stream-abc' + const ttlSeconds = 60 + + it('returns true when the caller still owns the lock and EXPIRE succeeds', async () => { + mockRedisInstance.eval.mockResolvedValueOnce(1) + + const extended = await extendLock(lockKey, value, ttlSeconds) + + expect(extended).toBe(true) + expect(mockRedisInstance.eval).toHaveBeenCalledWith( + expect.stringContaining('expire'), + 1, + lockKey, + value, + ttlSeconds + ) + }) + + it('returns false when the value does not match (lock owned by another)', async () => { + mockRedisInstance.eval.mockResolvedValueOnce(0) + + const extended = await extendLock(lockKey, value, ttlSeconds) + + expect(extended).toBe(false) + }) + + it('returns true as a no-op when Redis is unavailable', async () => { + vi.resetModules() + vi.doMock('@/lib/core/config/env', () => + createEnvMock({ REDIS_URL: undefined as unknown as string }) + ) + const { extendLock: extendLockNoRedis } = await import('@/lib/core/config/redis') + + const extended = await extendLockNoRedis(lockKey, value, ttlSeconds) + + expect(extended).toBe(true) + vi.doUnmock('@/lib/core/config/env') + }) + }) + describe('retryStrategy', () => { function captureRetryStrategy(): (times: number) => number { let capturedConfig: Record = {} diff --git a/apps/sim/lib/core/config/redis.ts b/apps/sim/lib/core/config/redis.ts index a603a2bad3b..7e60fbe8e81 100644 --- a/apps/sim/lib/core/config/redis.ts +++ b/apps/sim/lib/core/config/redis.ts @@ -136,6 +136,21 @@ else end ` +/** + * Lua script for safe lock TTL extension. + * Only refreshes the expiry if the value matches (ownership verification), + * so a stale heartbeat from a prior owner cannot extend a lock currently + * held by someone else after a TTL eviction. + * Returns 1 if the TTL was extended, 0 if not (value mismatch or key gone). + */ +const EXTEND_LOCK_SCRIPT = ` +if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("expire", KEYS[1], ARGV[2]) +else + return 0 +end +` + /** * Acquire a distributed lock using Redis SET NX. * Returns true if lock acquired, false if already held. @@ -175,6 +190,29 @@ export async function releaseLock(lockKey: string, value: string): Promise { + const redis = getRedisClient() + if (!redis) { + return true + } + + const result = await redis.eval(EXTEND_LOCK_SCRIPT, 1, lockKey, value, expirySeconds) + return result === 1 +} + /** * Close the Redis connection. * Use for graceful shutdown. diff --git a/packages/testing/src/mocks/redis-config.mock.ts b/packages/testing/src/mocks/redis-config.mock.ts index b40cf71eeef..7d90711a865 100644 --- a/packages/testing/src/mocks/redis-config.mock.ts +++ b/packages/testing/src/mocks/redis-config.mock.ts @@ -17,6 +17,7 @@ export const redisConfigMockFns = { mockOnRedisReconnect: vi.fn(), mockAcquireLock: vi.fn().mockResolvedValue(true), mockReleaseLock: vi.fn().mockResolvedValue(true), + mockExtendLock: vi.fn().mockResolvedValue(true), mockCloseRedisConnection: vi.fn().mockResolvedValue(undefined), mockResetForTesting: vi.fn(), } @@ -34,6 +35,7 @@ export const redisConfigMock = { onRedisReconnect: redisConfigMockFns.mockOnRedisReconnect, acquireLock: redisConfigMockFns.mockAcquireLock, releaseLock: redisConfigMockFns.mockReleaseLock, + extendLock: redisConfigMockFns.mockExtendLock, closeRedisConnection: redisConfigMockFns.mockCloseRedisConnection, resetForTesting: redisConfigMockFns.mockResetForTesting, } diff --git a/packages/testing/src/mocks/redis.mock.ts b/packages/testing/src/mocks/redis.mock.ts index d32f0286bd0..6771714cb2e 100644 --- a/packages/testing/src/mocks/redis.mock.ts +++ b/packages/testing/src/mocks/redis.mock.ts @@ -56,6 +56,9 @@ export function createMockRedis() { exec: vi.fn().mockResolvedValue([]), })), + // Scripting + eval: vi.fn().mockResolvedValue(0), + // Connection ping: vi.fn().mockResolvedValue('PONG'), quit: vi.fn().mockResolvedValue('OK'), From 56044776d57d089a33cd8e6e201d713877bbf113 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 24 Apr 2026 11:56:53 -0700 Subject: [PATCH 2/9] improvement(mothership): stream retry state machine, progressive re-rendering (#4287) * improvement(mothership): stream rety state machine, progressive re-rendering * address comments --- .../app/api/copilot/chat/stream/route.test.ts | 2 + apps/sim/app/api/copilot/chat/stream/route.ts | 45 +++++- .../mothership-chat/mothership-chat.tsx | 33 ++++- apps/sim/hooks/use-progressive-list.ts | 140 +++++++++++------- .../sim/lib/copilot/request/go/stream.test.ts | 58 ++++++++ apps/sim/lib/copilot/request/go/stream.ts | 32 ++-- 6 files changed, 230 insertions(+), 80 deletions(-) diff --git a/apps/sim/app/api/copilot/chat/stream/route.test.ts b/apps/sim/app/api/copilot/chat/stream/route.test.ts index 803f91af2ca..aa7c85b250f 100644 --- a/apps/sim/app/api/copilot/chat/stream/route.test.ts +++ b/apps/sim/app/api/copilot/chat/stream/route.test.ts @@ -38,6 +38,7 @@ vi.mock('@/lib/copilot/request/session', () => ({ }), encodeSSEEnvelope: (event: Record) => new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`), + encodeSSEComment: (comment: string) => new TextEncoder().encode(`: ${comment}\n\n`), SSE_RESPONSE_HEADERS: { 'Content-Type': 'text/event-stream', }, @@ -132,6 +133,7 @@ describe('copilot chat stream replay route', () => { ) const chunks = await readAllChunks(response) + expect(chunks[0]).toBe(': accepted\n\n') expect(chunks.join('')).toContain( JSON.stringify({ status: MothershipStreamV1CompletionStatus.cancelled, diff --git a/apps/sim/app/api/copilot/chat/stream/route.ts b/apps/sim/app/api/copilot/chat/stream/route.ts index 45a4c3c9875..3d7ab03b438 100644 --- a/apps/sim/app/api/copilot/chat/stream/route.ts +++ b/apps/sim/app/api/copilot/chat/stream/route.ts @@ -19,6 +19,7 @@ import { getCopilotTracer, markSpanForError } from '@/lib/copilot/request/otel' import { checkForReplayGap, createEvent, + encodeSSEComment, encodeSSEEnvelope, readEvents, readFilePreviewSessions, @@ -31,6 +32,7 @@ export const maxDuration = 3600 const logger = createLogger('CopilotChatStreamAPI') const POLL_INTERVAL_MS = 250 +const REPLAY_KEEPALIVE_INTERVAL_MS = 15_000 const MAX_STREAM_MS = 60 * 60 * 1000 function extractCanonicalRequestId(value: unknown): string { @@ -266,6 +268,7 @@ async function handleResumeRequestBody({ let controllerClosed = false let sawTerminalEvent = false let currentRequestId = extractRunRequestId(run) + let lastWriteTime = Date.now() // Stamp the logical request id + chat id on the resume root as soon // as we resolve them from the run row, so TraceQL joins work on // resume legs the same way they do on the original POST. @@ -291,6 +294,19 @@ async function handleResumeRequestBody({ if (controllerClosed) return false try { controller.enqueue(encodeSSEEnvelope(payload)) + lastWriteTime = Date.now() + return true + } catch { + controllerClosed = true + return false + } + } + + const enqueueComment = (comment: string) => { + if (controllerClosed) return false + try { + controller.enqueue(encodeSSEComment(comment)) + lastWriteTime = Date.now() return true } catch { controllerClosed = true @@ -306,7 +322,6 @@ async function handleResumeRequestBody({ const flushEvents = async () => { const events = await readEvents(streamId, cursor) if (events.length > 0) { - totalEventsFlushed += events.length logger.debug('[Resume] Flushing events', { streamId, afterCursor: cursor, @@ -314,14 +329,15 @@ async function handleResumeRequestBody({ }) } for (const envelope of events) { + if (!enqueueEvent(envelope)) { + break + } + totalEventsFlushed += 1 cursor = envelope.stream.cursor ?? String(envelope.seq) currentRequestId = extractEnvelopeRequestId(envelope) || currentRequestId if (envelope.type === MothershipStreamV1EventType.complete) { sawTerminalEvent = true } - if (!enqueueEvent(envelope)) { - break - } } } @@ -341,21 +357,30 @@ async function handleResumeRequestBody({ reason: options?.reason, requestId: currentRequestId, })) { + if (!enqueueEvent(envelope)) { + break + } cursor = envelope.stream.cursor ?? String(envelope.seq) if (envelope.type === MothershipStreamV1EventType.complete) { sawTerminalEvent = true } - if (!enqueueEvent(envelope)) { - break - } } } try { + enqueueComment('accepted') + const gap = await checkForReplayGap(streamId, afterCursor, currentRequestId) if (gap) { for (const envelope of gap.envelopes) { - enqueueEvent(envelope) + if (!enqueueEvent(envelope)) { + break + } + cursor = envelope.stream.cursor ?? String(envelope.seq) + currentRequestId = extractEnvelopeRequestId(envelope) || currentRequestId + if (envelope.type === MothershipStreamV1EventType.complete) { + sawTerminalEvent = true + } } return } @@ -408,6 +433,10 @@ async function handleResumeRequestBody({ break } + if (Date.now() - lastWriteTime >= REPLAY_KEEPALIVE_INTERVAL_MS) { + enqueueComment('keepalive') + } + await sleep(POLL_INTERVAL_MS) } if (!controllerClosed && Date.now() - startTime >= MAX_STREAM_MS) { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index b477c4744e4..ccf194d7328 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useLayoutEffect, useRef } from 'react' +import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' import { cn } from '@/lib/core/utils/cn' import { MessageActions } from '@/app/workspace/[workspaceId]/components' import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments' @@ -22,6 +22,7 @@ import type { QueuedMessage, } from '@/app/workspace/[workspaceId]/home/types' import { useAutoScroll } from '@/hooks/use-auto-scroll' +import { useProgressiveList } from '@/hooks/use-progressive-list' import type { ChatContext } from '@/stores/panel' import { MothershipChatSkeleton } from './mothership-chat-skeleton' @@ -104,6 +105,21 @@ export function MothershipChat({ scrollOnMount: true, }) const hasMessages = messages.length > 0 + const stagingKey = chatId ?? 'pending-chat' + const { staged: stagedMessages, isStaging } = useProgressiveList(messages, stagingKey) + const stagedMessageCount = stagedMessages.length + const stagedOffset = messages.length - stagedMessages.length + const precedingUserContentByIndex = useMemo(() => { + const contentByIndex: Array = [] + let lastUserContent: string | undefined + for (const [index, message] of messages.entries()) { + contentByIndex[index] = lastUserContent + if (message.role === 'user') { + lastUserContent = message.content + } + } + return contentByIndex + }, [messages]) const initialScrollDoneRef = useRef(false) const userInputRef = useRef(null) const handleSendQueuedHead = useCallback(() => { @@ -134,6 +150,11 @@ export function MothershipChat({ scrollToBottom() }, [hasMessages, initialScrollBlocked, scrollToBottom]) + useLayoutEffect(() => { + if (!isStaging || initialScrollBlocked || !initialScrollDoneRef.current) return + scrollToBottom() + }, [isStaging, stagedMessageCount, initialScrollBlocked, scrollToBottom]) + return (
@@ -141,7 +162,8 @@ export function MothershipChat({ ) : (
- {messages.map((msg, index) => { + {stagedMessages.map((msg, localIndex) => { + const index = stagedOffset + localIndex if (msg.role === 'user') { const hasAttachments = Boolean(msg.attachments?.length) return ( @@ -177,10 +199,7 @@ export function MothershipChat({ } const isLastMessage = index === messages.length - 1 - const precedingUserMsg = [...messages] - .slice(0, index) - .reverse() - .find((m) => m.role === 'user') + const precedingUserContent = precedingUserContentByIndex[index] return (
@@ -196,7 +215,7 @@ export function MothershipChat({
diff --git a/apps/sim/hooks/use-progressive-list.ts b/apps/sim/hooks/use-progressive-list.ts index 74d7dc87a90..bf60f4d67b0 100644 --- a/apps/sim/hooks/use-progressive-list.ts +++ b/apps/sim/hooks/use-progressive-list.ts @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' interface ProgressiveListOptions { /** Number of items to render in the initial batch (most recent items) */ @@ -14,15 +14,31 @@ const DEFAULTS = { batchSize: 5, } satisfies Required +interface ProgressiveListState { + key: string + count: number + caughtUp: boolean +} + +function createInitialState( + key: string, + itemCount: number, + initialBatch: number +): ProgressiveListState { + const count = Math.min(itemCount, initialBatch) + return { + key, + count, + caughtUp: itemCount > 0 && count >= itemCount, + } +} + /** * Progressively renders a list of items so that first paint is fast. * * On mount (or when `key` changes), only the most recent `initialBatch` * items are rendered. The rest are added in `batchSize` increments via - * `requestAnimationFrame` so the browser never blocks on a large DOM mount. - * - * Once staging completes for a given key it never re-stages -- new items - * appended to the list are rendered immediately. + * `requestAnimationFrame`. * * @param items Full list of items to render. * @param key A session/conversation identifier. When it changes, @@ -35,67 +51,83 @@ export function useProgressiveList( key: string, options?: ProgressiveListOptions ): { staged: T[]; isStaging: boolean } { - const initialBatch = options?.initialBatch ?? DEFAULTS.initialBatch - const batchSize = options?.batchSize ?? DEFAULTS.batchSize + const initialBatch = Math.max(0, options?.initialBatch ?? DEFAULTS.initialBatch) + const batchSize = Math.max(1, options?.batchSize ?? DEFAULTS.batchSize) + const [state, setState] = useState(() => createInitialState(key, items.length, initialBatch)) + const latestItemCountRef = useRef(items.length) + + useLayoutEffect(() => { + latestItemCountRef.current = items.length + }, [items.length]) - const completedKeysRef = useRef(new Set()) - const prevKeyRef = useRef(key) - const stagingCountRef = useRef(initialBatch) - const [count, setCount] = useState(() => { - if (items.length <= initialBatch) return items.length - return initialBatch - }) + const renderState = + state.key === key && (state.count > 0 || items.length === 0 || state.caughtUp) + ? state + : createInitialState(key, items.length, initialBatch) useEffect(() => { - if (completedKeysRef.current.has(key)) { - setCount(items.length) - return - } + setState((prev) => { + if (prev.key !== key) { + return createInitialState(key, items.length, initialBatch) + } - if (items.length <= initialBatch) { - setCount(items.length) - completedKeysRef.current.add(key) - return - } + if (items.length === 0) { + if (prev.count === 0 && !prev.caughtUp) { + return prev + } + return { key, count: 0, caughtUp: false } + } - let current = Math.max(stagingCountRef.current, initialBatch) - setCount(current) + if (prev.caughtUp) { + if (prev.count === items.length) { + return prev + } + return { key, count: items.length, caughtUp: true } + } - let frame: number | undefined + const minimumCount = Math.min(items.length, initialBatch) + if (prev.count >= minimumCount && prev.count <= items.length) { + return prev + } - const step = () => { - const total = items.length - current = Math.min(total, current + batchSize) - stagingCountRef.current = current - setCount(current) - if (current >= total) { - completedKeysRef.current.add(key) - frame = undefined - return + const count = Math.min(items.length, Math.max(prev.count, minimumCount)) + return { + key, + count, + caughtUp: count >= items.length, } - frame = requestAnimationFrame(step) + }) + }, [key, items.length, initialBatch]) + + useEffect(() => { + if (state.key !== key || state.caughtUp || state.count >= items.length) { + return } - frame = requestAnimationFrame(step) + const frame = requestAnimationFrame(() => { + setState((prev) => { + if (prev.key !== key || prev.caughtUp) { + return prev + } - return () => { - if (frame !== undefined) cancelAnimationFrame(frame) - } - }, [key, items.length, initialBatch, batchSize]) + const itemCount = latestItemCountRef.current + const count = Math.min(itemCount, prev.count + batchSize) + return { + key, + count, + caughtUp: count >= itemCount, + } + }) + }) - let effectiveCount = count - if (prevKeyRef.current !== key) { - effectiveCount = items.length <= initialBatch ? items.length : initialBatch - stagingCountRef.current = initialBatch - } - prevKeyRef.current = key - - const isCompleted = completedKeysRef.current.has(key) - const isStaging = !isCompleted && effectiveCount < items.length - const staged = - isCompleted || effectiveCount >= items.length - ? items - : items.slice(Math.max(0, items.length - effectiveCount)) + return () => cancelAnimationFrame(frame) + }, [state.key, state.count, state.caughtUp, key, items.length, batchSize]) + + const effectiveCount = renderState.caughtUp + ? items.length + : Math.min(renderState.count, items.length) + const staged = items.slice(Math.max(0, items.length - effectiveCount)) + const isStaging = effectiveCount < items.length return { staged, isStaging } } diff --git a/apps/sim/lib/copilot/request/go/stream.test.ts b/apps/sim/lib/copilot/request/go/stream.test.ts index f9f80384c8d..8234658ddcc 100644 --- a/apps/sim/lib/copilot/request/go/stream.test.ts +++ b/apps/sim/lib/copilot/request/go/stream.test.ts @@ -194,6 +194,64 @@ describe('copilot go stream helpers', () => { ) }) + it('does not retry transient backend statuses because stream requests are not idempotent', async () => { + vi.mocked(fetch).mockResolvedValueOnce(new Response('bad gateway', { status: 502 })) + + const context = createStreamingContext() + const execContext: ExecutionContext = { + userId: 'user-1', + workflowId: 'workflow-1', + } + + await expect( + runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, { + timeout: 1000, + }) + ).rejects.toMatchObject({ + name: 'CopilotBackendError', + status: 502, + body: 'bad gateway', + }) + + expect(fetch).toHaveBeenCalledTimes(1) + }) + + it('does not retry non-transient backend statuses before the SSE stream opens', async () => { + vi.mocked(fetch).mockResolvedValueOnce(new Response('limit reached', { status: 402 })) + + const context = createStreamingContext() + const execContext: ExecutionContext = { + userId: 'user-1', + workflowId: 'workflow-1', + } + + await expect( + runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, { + timeout: 1000, + }) + ).rejects.toThrow('Usage limit reached') + + expect(fetch).toHaveBeenCalledTimes(1) + }) + + it('does not retry network errors because Go may already be executing the request', async () => { + vi.mocked(fetch).mockRejectedValueOnce(new TypeError('fetch failed')) + + const context = createStreamingContext() + const execContext: ExecutionContext = { + userId: 'user-1', + workflowId: 'workflow-1', + } + + await expect( + runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, { + timeout: 1000, + }) + ).rejects.toThrow('fetch failed') + + expect(fetch).toHaveBeenCalledTimes(1) + }) + it('fails closed when the shared stream ends before a terminal event', async () => { const textEvent = createEvent({ streamId: 'stream-1', diff --git a/apps/sim/lib/copilot/request/go/stream.ts b/apps/sim/lib/copilot/request/go/stream.ts index a3e42f94371..d1796530867 100644 --- a/apps/sim/lib/copilot/request/go/stream.ts +++ b/apps/sim/lib/copilot/request/go/stream.ts @@ -134,17 +134,27 @@ export async function runStreamLoop( requestBodyBytes, }) const fetchStart = performance.now() - const response = await fetchGo(fetchUrl, { - ...fetchOptions, - signal: abortSignal, - otelContext: options.otelContext, - spanName: `sim → go ${pathname}`, - operation: 'stream', - attributes: { - [TraceAttr.CopilotStream]: true, - ...(requestBodyBytes ? { [TraceAttr.HttpRequestContentLength]: requestBodyBytes } : {}), - }, - }) + let response: Response + try { + response = await fetchGo(fetchUrl, { + ...fetchOptions, + signal: abortSignal, + otelContext: options.otelContext, + spanName: `sim → go ${pathname}`, + operation: 'stream', + attributes: { + [TraceAttr.CopilotStream]: true, + ...(requestBodyBytes ? { [TraceAttr.HttpRequestContentLength]: requestBodyBytes } : {}), + }, + }) + } catch (error) { + fetchSpan.attributes = { + ...(fetchSpan.attributes ?? {}), + headersMs: Math.round(performance.now() - fetchStart), + } + context.trace.endSpan(fetchSpan, abortSignal?.aborted ? 'cancelled' : 'error') + throw error + } const headersElapsedMs = Math.round(performance.now() - fetchStart) fetchSpan.attributes = { ...(fetchSpan.attributes ?? {}), From efc868263a350316241a08cda5474c8626354e54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?d=20=F0=9F=94=B9?= Date: Sat, 25 Apr 2026 02:59:48 +0800 Subject: [PATCH 3/9] fix(copilot): replace crypto.randomUUID() with generateId() per project rule (#4268) AGENTS.md / CLAUDE.md forbid crypto.randomUUID() (non-secure contexts throw TypeError in browsers). Four copilot server-side files still violated this rule, left over after PR #3397 polyfilled the client. Routes through request lifecycle, OAuth draft insertion, persisted message normalization, and table-row generation now use generateId from @sim/utils/id, which is a drop-in UUID v4 producer that falls back to crypto.getRandomValues when randomUUID is unavailable. Refs #3393. From f330fe22a271f0722905e569d824908cb1a5c3b5 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 24 Apr 2026 13:29:26 -0700 Subject: [PATCH 4/9] refactor(ashby): align tools, block, and triggers with Ashby API (#4288) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(ashby): align tools, block, and triggers with Ashby API Audit-driven refactor to destructure rich fields per Ashby's API docs, centralize output shapes via shared mappers in tools/ashby/utils.ts, and align webhook provider handler with trigger IDs via a shared action map. Removes stale block outputs left over from prior flat response shapes. Co-Authored-By: Claude Opus 4.7 * fix(ashby): remove stale noteId output and reject ping events - Remove stale `noteId` output descriptor from block (create_note now returns `id` at the top level via the shared note mapper). - Explicitly reject `ping` events in the webhook matchEvent before falling back to the generic triggerId check, so webhook records missing triggerId cannot execute workflows on Ashby ping probes. Co-Authored-By: Claude Opus 4.7 * fix(ashby): trim optional ID params in create/update tools Optional ID params in create_application, change_application_stage, and update_candidate were passed through to the request body without .trim(), unlike their required ID siblings. Normalize to prevent copy-paste whitespace errors. Co-Authored-By: Claude Opus 4.7 * fix(ashby): add subblock migration for removed filterCandidateId Add SUBBLOCK_ID_MIGRATIONS entry so deployed workflows that previously used the `filterCandidateId` subBlock on `list_applications` don't break after the field was removed (Ashby's application.list doesn't filter by candidateId). Also regenerate docs to sync noteId removal. Co-Authored-By: Claude Opus 4.7 * fix(ashby): final API alignment from parallel validation - create_candidate: email is optional per Ashby docs (only name is required); tool, types, and block all made non-required. - list_applications: guard NaN when createdAfter can't be parsed so we don't send a bad value to Ashby's API. - webhook provider: replace createHmacVerifier with explicit fail-closed verifyAuth that 401s when secretToken, signature header, or signature match is missing (was previously fail-open on missing secret). Co-Authored-By: Claude Opus 4.7 * fix(ashby): preserve input.data path in webhook formatInput Restore the explicit `data` key alongside the spread so deployed workflows that reference `input.data.application.*`, `input.data.candidate.*`, etc. keep working. The spread alone dropped those paths. Co-Authored-By: Claude Opus 4.7 * refactor(ashby): drop legacy input.data key from webhook formatInput Keep formatInput aligned with the advertised trigger outputs schema (flat top-level entities) and drop the legacy input.data.* compat path. Every field declared in each trigger's outputs is now populated 1:1 by the data spread plus the explicit action key — no undeclared keys. Co-Authored-By: Claude Opus 4.7 * fix(ashby): trim remaining ID body params for parity Add .trim() on sourceId (create_candidate), jobId (list_applications), applicationId and interviewStageId (list_interviews) to match the trim-on-IDs pattern used across the rest of the Ashby tools and guard against copy-paste whitespace. Co-Authored-By: Claude Opus 4.7 * update docs --------- Co-authored-by: Claude Opus 4.7 --- apps/docs/content/docs/en/tools/ashby.mdx | 619 +++++++++---- apps/docs/content/docs/en/triggers/ashby.mdx | 8 + .../integrations/data/integrations.json | 6 +- apps/sim/blocks/blocks/ashby.ts | 154 +++- apps/sim/lib/webhooks/providers/ashby.ts | 91 +- .../migrations/subblock-migrations.ts | 1 + apps/sim/tools/ashby/add_candidate_tag.ts | 20 +- .../tools/ashby/change_application_stage.ts | 25 +- apps/sim/tools/ashby/create_application.ts | 28 +- apps/sim/tools/ashby/create_candidate.ts | 57 +- apps/sim/tools/ashby/create_note.ts | 34 +- apps/sim/tools/ashby/get_application.ts | 125 +-- apps/sim/tools/ashby/get_candidate.ts | 92 +- apps/sim/tools/ashby/get_job.ts | 41 +- apps/sim/tools/ashby/get_job_posting.ts | 377 +++++++- apps/sim/tools/ashby/get_offer.ts | 67 +- apps/sim/tools/ashby/list_applications.ts | 106 +-- apps/sim/tools/ashby/list_archive_reasons.ts | 40 +- apps/sim/tools/ashby/list_candidate_tags.ts | 75 +- apps/sim/tools/ashby/list_candidates.ts | 58 +- apps/sim/tools/ashby/list_custom_fields.ts | 75 +- apps/sim/tools/ashby/list_departments.ts | 41 +- apps/sim/tools/ashby/list_interviews.ts | 171 +++- apps/sim/tools/ashby/list_job_postings.ts | 143 ++- apps/sim/tools/ashby/list_jobs.ts | 29 +- apps/sim/tools/ashby/list_locations.ts | 116 ++- apps/sim/tools/ashby/list_notes.ts | 33 +- apps/sim/tools/ashby/list_offers.ts | 69 +- apps/sim/tools/ashby/list_openings.ts | 37 +- apps/sim/tools/ashby/list_sources.ts | 39 +- apps/sim/tools/ashby/list_users.ts | 36 +- apps/sim/tools/ashby/remove_candidate_tag.ts | 20 +- apps/sim/tools/ashby/search_candidates.ts | 61 +- apps/sim/tools/ashby/types.ts | 348 +++++-- apps/sim/tools/ashby/update_candidate.ts | 94 +- apps/sim/tools/ashby/utils.ts | 848 ++++++++++++++++++ apps/sim/triggers/ashby/utils.ts | 42 +- 37 files changed, 2850 insertions(+), 1376 deletions(-) create mode 100644 apps/sim/tools/ashby/utils.ts diff --git a/apps/docs/content/docs/en/tools/ashby.mdx b/apps/docs/content/docs/en/tools/ashby.mdx index 138368e36c6..f75589fa9c4 100644 --- a/apps/docs/content/docs/en/tools/ashby.mdx +++ b/apps/docs/content/docs/en/tools/ashby.mdx @@ -38,7 +38,7 @@ Integrate Ashby into the workflow. Manage candidates (list, get, create, update, ### `ashby_add_candidate_tag` -Adds a tag to a candidate in Ashby. +Adds a tag to a candidate in Ashby and returns the updated candidate. #### Input @@ -52,7 +52,37 @@ Adds a tag to a candidate in Ashby. | Parameter | Type | Description | | --------- | ---- | ----------- | -| `success` | boolean | Whether the tag was successfully added | +| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) | +| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion, compensation with compensationTiers\[\]\) | +| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) | +| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) | +| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | +| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | +| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | +| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) | +| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | +| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | +| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | +| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) | +| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId\) | +| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) | +| `tags` | json | List of candidate tags \(id, title, isArchived\) | +| `id` | string | Resource UUID | +| `name` | string | Resource name | +| `title` | string | Job title or job posting title | +| `status` | string | Status | +| `candidate` | json | Candidate details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) | +| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, createdAt, updatedAt\) | +| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) | +| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) | +| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) | +| `content` | string | Note content | +| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) | +| `isPrivate` | boolean | Whether the note is private | +| `createdAt` | string | ISO 8601 creation timestamp | +| `moreDataAvailable` | boolean | Whether more pages exist | +| `nextCursor` | string | Pagination cursor for next page | +| `syncToken` | string | Sync token for incremental updates | ### `ashby_change_application_stage` @@ -71,8 +101,37 @@ Moves an application to a different interview stage. Requires an archive reason | Parameter | Type | Description | | --------- | ---- | ----------- | -| `applicationId` | string | Application UUID | -| `stageId` | string | New interview stage UUID | +| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) | +| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion, compensation with compensationTiers\[\]\) | +| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) | +| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) | +| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | +| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | +| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | +| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) | +| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | +| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | +| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | +| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) | +| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId\) | +| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) | +| `tags` | json | List of candidate tags \(id, title, isArchived\) | +| `id` | string | Resource UUID | +| `name` | string | Resource name | +| `title` | string | Job title or job posting title | +| `status` | string | Status | +| `candidate` | json | Candidate details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) | +| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, createdAt, updatedAt\) | +| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) | +| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) | +| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) | +| `content` | string | Note content | +| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) | +| `isPrivate` | boolean | Whether the note is private | +| `createdAt` | string | ISO 8601 creation timestamp | +| `moreDataAvailable` | boolean | Whether more pages exist | +| `nextCursor` | string | Pagination cursor for next page | +| `syncToken` | string | Sync token for incremental updates | ### `ashby_create_application` @@ -95,7 +154,37 @@ Creates a new application for a candidate on a job. Optionally specify interview | Parameter | Type | Description | | --------- | ---- | ----------- | -| `applicationId` | string | Created application UUID | +| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) | +| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion, compensation with compensationTiers\[\]\) | +| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) | +| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) | +| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | +| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | +| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | +| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) | +| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | +| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | +| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | +| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) | +| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId\) | +| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) | +| `tags` | json | List of candidate tags \(id, title, isArchived\) | +| `id` | string | Resource UUID | +| `name` | string | Resource name | +| `title` | string | Job title or job posting title | +| `status` | string | Status | +| `candidate` | json | Candidate details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) | +| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, createdAt, updatedAt\) | +| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) | +| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) | +| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) | +| `content` | string | Note content | +| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) | +| `isPrivate` | boolean | Whether the note is private | +| `createdAt` | string | ISO 8601 creation timestamp | +| `moreDataAvailable` | boolean | Whether more pages exist | +| `nextCursor` | string | Pagination cursor for next page | +| `syncToken` | string | Sync token for incremental updates | ### `ashby_create_candidate` @@ -107,7 +196,7 @@ Creates a new candidate record in Ashby. | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Ashby API Key | | `name` | string | Yes | The candidate full name | -| `email` | string | Yes | Primary email address for the candidate | +| `email` | string | No | Primary email address for the candidate | | `phoneNumber` | string | No | Primary phone number for the candidate | | `linkedInUrl` | string | No | LinkedIn profile URL | | `githubUrl` | string | No | GitHub profile URL | @@ -117,17 +206,37 @@ Creates a new candidate record in Ashby. | Parameter | Type | Description | | --------- | ---- | ----------- | -| `id` | string | Created candidate UUID | -| `name` | string | Full name | -| `primaryEmailAddress` | object | Primary email contact info | -| ↳ `value` | string | Email address | -| ↳ `type` | string | Contact type \(Personal, Work, Other\) | -| ↳ `isPrimary` | boolean | Whether this is the primary email | -| `primaryPhoneNumber` | object | Primary phone contact info | -| ↳ `value` | string | Phone number | -| ↳ `type` | string | Contact type \(Personal, Work, Other\) | -| ↳ `isPrimary` | boolean | Whether this is the primary phone | +| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) | +| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion, compensation with compensationTiers\[\]\) | +| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) | +| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) | +| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | +| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | +| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | +| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) | +| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | +| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | +| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | +| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) | +| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId\) | +| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) | +| `tags` | json | List of candidate tags \(id, title, isArchived\) | +| `id` | string | Resource UUID | +| `name` | string | Resource name | +| `title` | string | Job title or job posting title | +| `status` | string | Status | +| `candidate` | json | Candidate details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) | +| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, createdAt, updatedAt\) | +| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) | +| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) | +| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) | +| `content` | string | Note content | +| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) | +| `isPrivate` | boolean | Whether the note is private | | `createdAt` | string | ISO 8601 creation timestamp | +| `moreDataAvailable` | boolean | Whether more pages exist | +| `nextCursor` | string | Pagination cursor for next page | +| `syncToken` | string | Sync token for incremental updates | ### `ashby_create_note` @@ -147,7 +256,15 @@ Creates a note on a candidate in Ashby. Supports plain text and HTML content (bo | Parameter | Type | Description | | --------- | ---- | ----------- | -| `noteId` | string | Created note UUID | +| `id` | string | Created note UUID | +| `createdAt` | string | ISO 8601 creation timestamp | +| `isPrivate` | boolean | Whether the note is private | +| `content` | string | Note content | +| `author` | object | Author of the note | +| ↳ `id` | string | Author user UUID | +| ↳ `firstName` | string | Author first name | +| ↳ `lastName` | string | Author last name | +| ↳ `email` | string | Author email | ### `ashby_get_application` @@ -164,28 +281,37 @@ Retrieves full details about a single application by its ID. | Parameter | Type | Description | | --------- | ---- | ----------- | -| `id` | string | Application UUID | -| `status` | string | Application status \(Active, Hired, Archived, Lead\) | -| `candidate` | object | Associated candidate | -| ↳ `id` | string | Candidate UUID | -| ↳ `name` | string | Candidate name | -| `job` | object | Associated job | -| ↳ `id` | string | Job UUID | -| ↳ `title` | string | Job title | -| `currentInterviewStage` | object | Current interview stage | -| ↳ `id` | string | Stage UUID | -| ↳ `title` | string | Stage title | -| ↳ `type` | string | Stage type | -| `source` | object | Application source | -| ↳ `id` | string | Source UUID | -| ↳ `title` | string | Source title | -| `archiveReason` | object | Reason for archival | -| ↳ `id` | string | Reason UUID | -| ↳ `text` | string | Reason text | -| ↳ `reasonType` | string | Reason type | -| `archivedAt` | string | ISO 8601 archive timestamp | +| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) | +| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion, compensation with compensationTiers\[\]\) | +| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) | +| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) | +| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | +| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | +| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | +| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) | +| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | +| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | +| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | +| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) | +| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId\) | +| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) | +| `tags` | json | List of candidate tags \(id, title, isArchived\) | +| `id` | string | Resource UUID | +| `name` | string | Resource name | +| `title` | string | Job title or job posting title | +| `status` | string | Status | +| `candidate` | json | Candidate details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) | +| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, createdAt, updatedAt\) | +| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) | +| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) | +| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) | +| `content` | string | Note content | +| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) | +| `isPrivate` | boolean | Whether the note is private | | `createdAt` | string | ISO 8601 creation timestamp | -| `updatedAt` | string | ISO 8601 last update timestamp | +| `moreDataAvailable` | boolean | Whether more pages exist | +| `nextCursor` | string | Pagination cursor for next page | +| `syncToken` | string | Sync token for incremental updates | ### `ashby_get_candidate` @@ -202,27 +328,37 @@ Retrieves full details about a single candidate by their ID. | Parameter | Type | Description | | --------- | ---- | ----------- | -| `id` | string | Candidate UUID | -| `name` | string | Full name | -| `primaryEmailAddress` | object | Primary email contact info | -| ↳ `value` | string | Email address | -| ↳ `type` | string | Contact type \(Personal, Work, Other\) | -| ↳ `isPrimary` | boolean | Whether this is the primary email | -| `primaryPhoneNumber` | object | Primary phone contact info | -| ↳ `value` | string | Phone number | -| ↳ `type` | string | Contact type \(Personal, Work, Other\) | -| ↳ `isPrimary` | boolean | Whether this is the primary phone | -| `profileUrl` | string | URL to the candidate Ashby profile | -| `position` | string | Current position or title | -| `company` | string | Current company | -| `linkedInUrl` | string | LinkedIn profile URL | -| `githubUrl` | string | GitHub profile URL | -| `tags` | array | Tags applied to the candidate | -| ↳ `id` | string | Tag UUID | -| ↳ `title` | string | Tag title | -| `applicationIds` | array | IDs of associated applications | +| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) | +| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion, compensation with compensationTiers\[\]\) | +| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) | +| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) | +| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | +| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | +| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | +| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) | +| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | +| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | +| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | +| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) | +| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId\) | +| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) | +| `tags` | json | List of candidate tags \(id, title, isArchived\) | +| `id` | string | Resource UUID | +| `name` | string | Resource name | +| `title` | string | Job title or job posting title | +| `status` | string | Status | +| `candidate` | json | Candidate details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) | +| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, createdAt, updatedAt\) | +| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) | +| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) | +| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) | +| `content` | string | Note content | +| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) | +| `isPrivate` | boolean | Whether the note is private | | `createdAt` | string | ISO 8601 creation timestamp | -| `updatedAt` | string | ISO 8601 last update timestamp | +| `moreDataAvailable` | boolean | Whether more pages exist | +| `nextCursor` | string | Pagination cursor for next page | +| `syncToken` | string | Sync token for incremental updates | ### `ashby_get_job` @@ -239,16 +375,37 @@ Retrieves full details about a single job by its ID. | Parameter | Type | Description | | --------- | ---- | ----------- | -| `id` | string | Job UUID | -| `title` | string | Job title | -| `status` | string | Job status \(Open, Closed, Draft, Archived\) | -| `employmentType` | string | Employment type \(FullTime, PartTime, Intern, Contract, Temporary\) | -| `departmentId` | string | Department UUID | -| `locationId` | string | Location UUID | -| `descriptionPlain` | string | Job description in plain text | -| `isArchived` | boolean | Whether the job is archived | +| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) | +| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion, compensation with compensationTiers\[\]\) | +| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) | +| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) | +| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | +| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | +| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | +| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) | +| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | +| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | +| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | +| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) | +| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId\) | +| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) | +| `tags` | json | List of candidate tags \(id, title, isArchived\) | +| `id` | string | Resource UUID | +| `name` | string | Resource name | +| `title` | string | Job title or job posting title | +| `status` | string | Status | +| `candidate` | json | Candidate details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) | +| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, createdAt, updatedAt\) | +| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) | +| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) | +| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) | +| `content` | string | Note content | +| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) | +| `isPrivate` | boolean | Whether the note is private | | `createdAt` | string | ISO 8601 creation timestamp | -| `updatedAt` | string | ISO 8601 last update timestamp | +| `moreDataAvailable` | boolean | Whether more pages exist | +| `nextCursor` | string | Pagination cursor for next page | +| `syncToken` | string | Sync token for incremental updates | ### `ashby_get_job_posting` @@ -260,6 +417,8 @@ Retrieves full details about a single job posting by its ID. | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Ashby API Key | | `jobPostingId` | string | Yes | The UUID of the job posting to fetch | +| `expandApplicationFormDefinition` | boolean | No | Include application form definition in the response | +| `expandSurveyFormDefinitions` | boolean | No | Include survey form definitions in the response | #### Output @@ -267,14 +426,56 @@ Retrieves full details about a single job posting by its ID. | --------- | ---- | ----------- | | `id` | string | Job posting UUID | | `title` | string | Job posting title | -| `jobId` | string | Associated job UUID | -| `locationName` | string | Location name | +| `descriptionPlain` | string | Full description in plain text | +| `descriptionHtml` | string | Full description in HTML | +| `descriptionSocial` | string | Shortened description for social sharing \(max 200 chars\) | +| `descriptionParts` | object | Description broken into opening, body, and closing sections | +| ↳ `descriptionOpening` | object | Opening \(from Job Boards theme settings\) | +| ↳ `html` | string | HTML content | +| ↳ `plain` | string | Plain text content | +| ↳ `descriptionBody` | object | Main description body | +| ↳ `html` | string | HTML content | +| ↳ `plain` | string | Plain text content | +| ↳ `descriptionClosing` | object | Closing \(from Job Boards theme settings\) | +| ↳ `html` | string | HTML content | +| ↳ `plain` | string | Plain text content | | `departmentName` | string | Department name | -| `employmentType` | string | Employment type \(e.g. FullTime, PartTime, Contract\) | -| `descriptionPlain` | string | Job posting description in plain text | -| `isListed` | boolean | Whether the posting is publicly listed | +| `teamName` | string | Team name | +| `teamNameHierarchy` | array | Hierarchy of team names from root to team | +| `jobId` | string | Associated job UUID | +| `locationName` | string | Primary location name | +| `locationIds` | object | Primary and secondary location UUIDs | +| ↳ `primaryLocationId` | string | Primary location UUID | +| ↳ `secondaryLocationIds` | array | Secondary location UUIDs | +| `address` | object | Postal address of the posting location | +| ↳ `postalAddress` | object | Structured postal address | +| ↳ `addressCountry` | string | Country | +| ↳ `addressRegion` | string | State or region | +| ↳ `addressLocality` | string | City or locality | +| ↳ `postalCode` | string | Postal code | +| ↳ `streetAddress` | string | Street address | +| `isRemote` | boolean | Whether the posting is remote | +| `workplaceType` | string | Workplace type \(OnSite, Remote, Hybrid\) | +| `employmentType` | string | Employment type \(FullTime, PartTime, Intern, Contract, Temporary\) | +| `isListed` | boolean | Whether publicly listed on the job board | +| `suppressDescriptionOpening` | boolean | Whether the theme opening is hidden on this posting | +| `suppressDescriptionClosing` | boolean | Whether the theme closing is hidden on this posting | | `publishedDate` | string | ISO 8601 published date | +| `applicationDeadline` | string | ISO 8601 application deadline | | `externalLink` | string | External link to the job posting | +| `applyLink` | string | Direct apply link | +| `compensation` | object | Compensation details for the posting | +| ↳ `compensationTierSummary` | string | Human-readable tier summary | +| ↳ `summaryComponents` | array | Structured compensation components | +| ↳ `summary` | string | Component summary | +| ↳ `compensationTypeLabel` | string | Component type label \(Salary, Commission, Bonus, Equity, etc.\) | +| ↳ `interval` | string | Payment interval \(e.g. annual, hourly\) | +| ↳ `currencyCode` | string | ISO 4217 currency code | +| ↳ `minValue` | number | Minimum value | +| ↳ `maxValue` | number | Maximum value | +| ↳ `shouldDisplayCompensationOnJobBoard` | boolean | Whether compensation is shown on the job board | +| `applicationLimitCalloutHtml` | string | HTML callout shown when application limit is reached | +| `updatedAt` | string | ISO 8601 last update timestamp | ### `ashby_get_offer` @@ -291,20 +492,41 @@ Retrieves full details about a single offer by its ID. | Parameter | Type | Description | | --------- | ---- | ----------- | -| `id` | string | Offer UUID | -| `offerStatus` | string | Offer status \(e.g. WaitingOnCandidateResponse, CandidateAccepted\) | -| `acceptanceStatus` | string | Acceptance status \(e.g. Accepted, Declined, Pending\) | -| `applicationId` | string | Associated application UUID | -| `startDate` | string | Offer start date | -| `salary` | object | Salary details | -| ↳ `currencyCode` | string | ISO 4217 currency code | -| ↳ `value` | number | Salary amount | -| `openingId` | string | Associated opening UUID | -| `createdAt` | string | ISO 8601 creation timestamp \(from latest version\) | +| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) | +| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion, compensation with compensationTiers\[\]\) | +| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) | +| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) | +| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | +| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | +| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | +| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) | +| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | +| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | +| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | +| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) | +| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId\) | +| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) | +| `tags` | json | List of candidate tags \(id, title, isArchived\) | +| `id` | string | Resource UUID | +| `name` | string | Resource name | +| `title` | string | Job title or job posting title | +| `status` | string | Status | +| `candidate` | json | Candidate details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) | +| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, createdAt, updatedAt\) | +| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) | +| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) | +| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) | +| `content` | string | Note content | +| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) | +| `isPrivate` | boolean | Whether the note is private | +| `createdAt` | string | ISO 8601 creation timestamp | +| `moreDataAvailable` | boolean | Whether more pages exist | +| `nextCursor` | string | Pagination cursor for next page | +| `syncToken` | string | Sync token for incremental updates | ### `ashby_list_applications` -Lists all applications in an Ashby organization with pagination and optional filters for status, job, candidate, and creation date. +Lists all applications in an Ashby organization with pagination and optional filters for status, job, and creation date. #### Input @@ -315,7 +537,6 @@ Lists all applications in an Ashby organization with pagination and optional fil | `perPage` | number | No | Number of results per page \(default 100\) | | `status` | string | No | Filter by application status: Active, Hired, Archived, or Lead | | `jobId` | string | No | Filter applications by a specific job UUID | -| `candidateId` | string | No | Filter applications by a specific candidate UUID | | `createdAfter` | string | No | Filter to applications created after this ISO 8601 timestamp \(e.g. 2024-01-01T00:00:00Z\) | #### Output @@ -323,23 +544,6 @@ Lists all applications in an Ashby organization with pagination and optional fil | Parameter | Type | Description | | --------- | ---- | ----------- | | `applications` | array | List of applications | -| ↳ `id` | string | Application UUID | -| ↳ `status` | string | Application status \(Active, Hired, Archived, Lead\) | -| ↳ `candidate` | object | Associated candidate | -| ↳ `id` | string | Candidate UUID | -| ↳ `name` | string | Candidate name | -| ↳ `job` | object | Associated job | -| ↳ `id` | string | Job UUID | -| ↳ `title` | string | Job title | -| ↳ `currentInterviewStage` | object | Current interview stage | -| ↳ `id` | string | Stage UUID | -| ↳ `title` | string | Stage title | -| ↳ `type` | string | Stage type | -| ↳ `source` | object | Application source | -| ↳ `id` | string | Source UUID | -| ↳ `title` | string | Source title | -| ↳ `createdAt` | string | ISO 8601 creation timestamp | -| ↳ `updatedAt` | string | ISO 8601 last update timestamp | | `moreDataAvailable` | boolean | Whether more pages of results exist | | `nextCursor` | string | Opaque cursor for fetching the next page | @@ -352,6 +556,7 @@ Lists all archive reasons configured in Ashby. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Ashby API Key | +| `includeArchived` | boolean | No | Whether to include archived archive reasons in the response \(default false\) | #### Output @@ -360,7 +565,7 @@ Lists all archive reasons configured in Ashby. | `archiveReasons` | array | List of archive reasons | | ↳ `id` | string | Archive reason UUID | | ↳ `text` | string | Archive reason text | -| ↳ `reasonType` | string | Reason type | +| ↳ `reasonType` | string | Reason type \(RejectedByCandidate, RejectedByOrg, Other\) | | ↳ `isArchived` | boolean | Whether the reason is archived | ### `ashby_list_candidate_tags` @@ -372,6 +577,10 @@ Lists all candidate tags configured in Ashby. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Ashby API Key | +| `includeArchived` | boolean | No | Whether to include archived candidate tags \(default false\) | +| `cursor` | string | No | Opaque pagination cursor from a previous response nextCursor value | +| `syncToken` | string | No | Sync token from a previous response to fetch only changed results | +| `perPage` | number | No | Number of results per page \(default 100\) | #### Output @@ -381,6 +590,9 @@ Lists all candidate tags configured in Ashby. | ↳ `id` | string | Tag UUID | | ↳ `title` | string | Tag title | | ↳ `isArchived` | boolean | Whether the tag is archived | +| `moreDataAvailable` | boolean | Whether more pages of results exist | +| `nextCursor` | string | Opaque cursor for fetching the next page | +| `syncToken` | string | Sync token to use for incremental updates in future requests | ### `ashby_list_candidates` @@ -399,18 +611,6 @@ Lists all candidates in an Ashby organization with cursor-based pagination. | Parameter | Type | Description | | --------- | ---- | ----------- | | `candidates` | array | List of candidates | -| ↳ `id` | string | Candidate UUID | -| ↳ `name` | string | Full name | -| ↳ `primaryEmailAddress` | object | Primary email contact info | -| ↳ `value` | string | Email address | -| ↳ `type` | string | Contact type \(Personal, Work, Other\) | -| ↳ `isPrimary` | boolean | Whether this is the primary email | -| ↳ `primaryPhoneNumber` | object | Primary phone contact info | -| ↳ `value` | string | Phone number | -| ↳ `type` | string | Contact type \(Personal, Work, Other\) | -| ↳ `isPrimary` | boolean | Whether this is the primary phone | -| ↳ `createdAt` | string | ISO 8601 creation timestamp | -| ↳ `updatedAt` | string | ISO 8601 last update timestamp | | `moreDataAvailable` | boolean | Whether more pages of results exist | | `nextCursor` | string | Opaque cursor for fetching the next page | @@ -431,9 +631,15 @@ Lists all custom field definitions configured in Ashby. | `customFields` | array | List of custom field definitions | | ↳ `id` | string | Custom field UUID | | ↳ `title` | string | Custom field title | -| ↳ `fieldType` | string | Field type \(e.g. String, Number, Boolean\) | -| ↳ `objectType` | string | Object type the field applies to \(e.g. Candidate, Application, Job\) | +| ↳ `isPrivate` | boolean | Whether the custom field is private | +| ↳ `fieldType` | string | Field data type \(MultiValueSelect, NumberRange, String, Date, ValueSelect, Number, Currency, Boolean, LongText, CompensationRange\) | +| ↳ `objectType` | string | Object type the field applies to \(Application, Candidate, Employee, Job, Offer, Opening, Talent_Project\) | | ↳ `isArchived` | boolean | Whether the custom field is archived | +| ↳ `isRequired` | boolean | Whether a value is required | +| ↳ `selectableValues` | array | Selectable values for MultiValueSelect fields \(empty for other field types\) | +| ↳ `label` | string | Display label | +| ↳ `value` | string | Stored value | +| ↳ `isArchived` | boolean | Whether archived | ### `ashby_list_departments` @@ -452,8 +658,11 @@ Lists all departments in Ashby. | `departments` | array | List of departments | | ↳ `id` | string | Department UUID | | ↳ `name` | string | Department name | +| ↳ `externalName` | string | Candidate-facing name used on job boards | | ↳ `isArchived` | boolean | Whether the department is archived | | ↳ `parentId` | string | Parent department UUID | +| ↳ `createdAt` | string | ISO 8601 creation timestamp | +| ↳ `updatedAt` | string | ISO 8601 last update timestamp | ### `ashby_list_interviews` @@ -475,10 +684,24 @@ Lists interview schedules in Ashby, optionally filtered by application or interv | --------- | ---- | ----------- | | `interviewSchedules` | array | List of interview schedules | | ↳ `id` | string | Interview schedule UUID | +| ↳ `status` | string | Schedule status \(NeedsScheduling, WaitingOnCandidateBooking, Scheduled, Complete, Cancelled, OnHold, etc.\) | | ↳ `applicationId` | string | Associated application UUID | | ↳ `interviewStageId` | string | Interview stage UUID | -| ↳ `status` | string | Schedule status | | ↳ `createdAt` | string | ISO 8601 creation timestamp | +| ↳ `updatedAt` | string | ISO 8601 last update timestamp | +| ↳ `interviewEvents` | array | Scheduled interview events on this schedule | +| ↳ `id` | string | Event UUID | +| ↳ `interviewId` | string | Interview template UUID | +| ↳ `interviewScheduleId` | string | Parent schedule UUID | +| ↳ `interviewerUserIds` | array | User UUIDs of interviewers assigned to the event | +| ↳ `createdAt` | string | Event creation timestamp | +| ↳ `updatedAt` | string | Event last updated timestamp | +| ↳ `startTime` | string | Event start time | +| ↳ `endTime` | string | Event end time | +| ↳ `feedbackLink` | string | URL to submit feedback for the event | +| ↳ `location` | string | Physical location | +| ↳ `meetingLink` | string | Virtual meeting URL | +| ↳ `hasSubmittedFeedback` | boolean | Whether any feedback has been submitted | | `moreDataAvailable` | boolean | Whether more pages of results exist | | `nextCursor` | string | Opaque cursor for fetching the next page | @@ -500,11 +723,22 @@ Lists all job postings in Ashby. | ↳ `id` | string | Job posting UUID | | ↳ `title` | string | Job posting title | | ↳ `jobId` | string | Associated job UUID | -| ↳ `locationName` | string | Location name | | ↳ `departmentName` | string | Department name | -| ↳ `employmentType` | string | Employment type \(e.g. FullTime, PartTime, Contract\) | +| ↳ `teamName` | string | Team name | +| ↳ `locationName` | string | Primary location display name | +| ↳ `locationIds` | object | Primary and secondary location UUIDs | +| ↳ `primaryLocationId` | string | Primary location UUID | +| ↳ `secondaryLocationIds` | array | Secondary location UUIDs | +| ↳ `workplaceType` | string | Workplace type \(OnSite, Remote, Hybrid\) | +| ↳ `employmentType` | string | Employment type \(FullTime, PartTime, Intern, Contract, Temporary\) | | ↳ `isListed` | boolean | Whether the posting is publicly listed | | ↳ `publishedDate` | string | ISO 8601 published date | +| ↳ `applicationDeadline` | string | ISO 8601 application deadline | +| ↳ `externalLink` | string | External link to the job posting | +| ↳ `applyLink` | string | Direct apply link for the job posting | +| ↳ `compensationTierSummary` | string | Compensation tier summary for job boards | +| ↳ `shouldDisplayCompensationOnJobBoard` | boolean | Whether compensation is shown on the job board | +| ↳ `updatedAt` | string | ISO 8601 last update timestamp | ### `ashby_list_jobs` @@ -524,14 +758,6 @@ Lists all jobs in an Ashby organization. By default returns Open, Closed, and Ar | Parameter | Type | Description | | --------- | ---- | ----------- | | `jobs` | array | List of jobs | -| ↳ `id` | string | Job UUID | -| ↳ `title` | string | Job title | -| ↳ `status` | string | Job status \(Open, Closed, Archived, Draft\) | -| ↳ `employmentType` | string | Employment type \(FullTime, PartTime, Intern, Contract, Temporary\) | -| ↳ `departmentId` | string | Department UUID | -| ↳ `locationId` | string | Location UUID | -| ↳ `createdAt` | string | ISO 8601 creation timestamp | -| ↳ `updatedAt` | string | ISO 8601 last update timestamp | | `moreDataAvailable` | boolean | Whether more pages of results exist | | `nextCursor` | string | Opaque cursor for fetching the next page | @@ -552,12 +778,18 @@ Lists all locations configured in Ashby. | `locations` | array | List of locations | | ↳ `id` | string | Location UUID | | ↳ `name` | string | Location name | +| ↳ `externalName` | string | Candidate-facing name used on job boards | | ↳ `isArchived` | boolean | Whether the location is archived | -| ↳ `isRemote` | boolean | Whether this is a remote location | -| ↳ `address` | object | Location address | -| ↳ `city` | string | City | -| ↳ `region` | string | State or region | -| ↳ `country` | string | Country | +| ↳ `isRemote` | boolean | Whether the location is remote \(use workplaceType instead\) | +| ↳ `workplaceType` | string | Workplace type \(OnSite, Hybrid, Remote\) | +| ↳ `parentLocationId` | string | Parent location UUID | +| ↳ `type` | string | Location component type \(Location, LocationHierarchy\) | +| ↳ `address` | object | Location postal address | +| ↳ `addressCountry` | string | Country | +| ↳ `addressRegion` | string | State or region | +| ↳ `addressLocality` | string | City or locality | +| ↳ `postalCode` | string | Postal code | +| ↳ `streetAddress` | string | Street address | ### `ashby_list_notes` @@ -579,6 +811,7 @@ Lists all notes on a candidate with pagination support. | `notes` | array | List of notes on the candidate | | ↳ `id` | string | Note UUID | | ↳ `content` | string | Note content | +| ↳ `isPrivate` | boolean | Whether the note is private | | ↳ `author` | object | Note author | | ↳ `id` | string | Author user UUID | | ↳ `firstName` | string | First name | @@ -605,16 +838,6 @@ Lists all offers with their latest version in an Ashby organization. | Parameter | Type | Description | | --------- | ---- | ----------- | | `offers` | array | List of offers | -| ↳ `id` | string | Offer UUID | -| ↳ `offerStatus` | string | Offer status | -| ↳ `acceptanceStatus` | string | Acceptance status | -| ↳ `applicationId` | string | Associated application UUID | -| ↳ `startDate` | string | Offer start date | -| ↳ `salary` | object | Salary details | -| ↳ `currencyCode` | string | ISO 4217 currency code | -| ↳ `value` | number | Salary amount | -| ↳ `openingId` | string | Associated opening UUID | -| ↳ `createdAt` | string | ISO 8601 creation timestamp | | `moreDataAvailable` | boolean | Whether more pages of results exist | | `nextCursor` | string | Opaque cursor for fetching the next page | @@ -634,12 +857,6 @@ Lists all openings in Ashby with pagination. | Parameter | Type | Description | | --------- | ---- | ----------- | -| `openings` | array | List of openings | -| ↳ `id` | string | Opening UUID | -| ↳ `openingState` | string | Opening state \(Approved, Closed, Draft, Filled, Open\) | -| ↳ `isArchived` | boolean | Whether the opening is archived | -| ↳ `openedAt` | string | ISO 8601 opened timestamp | -| ↳ `closedAt` | string | ISO 8601 closed timestamp | | `moreDataAvailable` | boolean | Whether more pages of results exist | | `nextCursor` | string | Opaque cursor for fetching the next page | @@ -661,6 +878,10 @@ Lists all candidate sources configured in Ashby. | ↳ `id` | string | Source UUID | | ↳ `title` | string | Source title | | ↳ `isArchived` | boolean | Whether the source is archived | +| ↳ `sourceType` | object | Source type grouping | +| ↳ `id` | string | Source type UUID | +| ↳ `title` | string | Source type title | +| ↳ `isArchived` | boolean | Whether archived | ### `ashby_list_users` @@ -679,18 +900,12 @@ Lists all users in Ashby with pagination. | Parameter | Type | Description | | --------- | ---- | ----------- | | `users` | array | List of users | -| ↳ `id` | string | User UUID | -| ↳ `firstName` | string | First name | -| ↳ `lastName` | string | Last name | -| ↳ `email` | string | Email address | -| ↳ `isEnabled` | boolean | Whether the user account is enabled | -| ↳ `globalRole` | string | User role \(Organization Admin, Elevated Access, Limited Access, External Recruiter\) | | `moreDataAvailable` | boolean | Whether more pages of results exist | | `nextCursor` | string | Opaque cursor for fetching the next page | ### `ashby_remove_candidate_tag` -Removes a tag from a candidate in Ashby. +Removes a tag from a candidate in Ashby and returns the updated candidate. #### Input @@ -704,7 +919,37 @@ Removes a tag from a candidate in Ashby. | Parameter | Type | Description | | --------- | ---- | ----------- | -| `success` | boolean | Whether the tag was successfully removed | +| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) | +| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion, compensation with compensationTiers\[\]\) | +| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) | +| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) | +| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | +| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | +| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | +| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) | +| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | +| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | +| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | +| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) | +| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId\) | +| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) | +| `tags` | json | List of candidate tags \(id, title, isArchived\) | +| `id` | string | Resource UUID | +| `name` | string | Resource name | +| `title` | string | Job title or job posting title | +| `status` | string | Status | +| `candidate` | json | Candidate details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) | +| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, createdAt, updatedAt\) | +| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) | +| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) | +| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) | +| `content` | string | Note content | +| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) | +| `isPrivate` | boolean | Whether the note is private | +| `createdAt` | string | ISO 8601 creation timestamp | +| `moreDataAvailable` | boolean | Whether more pages exist | +| `nextCursor` | string | Pagination cursor for next page | +| `syncToken` | string | Sync token for incremental updates | ### `ashby_search_candidates` @@ -723,18 +968,6 @@ Searches for candidates by name and/or email with AND logic. Results are limited | Parameter | Type | Description | | --------- | ---- | ----------- | | `candidates` | array | Matching candidates \(max 100 results\) | -| ↳ `id` | string | Candidate UUID | -| ↳ `name` | string | Full name | -| ↳ `primaryEmailAddress` | object | Primary email contact info | -| ↳ `value` | string | Email address | -| ↳ `type` | string | Contact type \(Personal, Work, Other\) | -| ↳ `isPrimary` | boolean | Whether this is the primary email | -| ↳ `primaryPhoneNumber` | object | Primary phone contact info | -| ↳ `value` | string | Phone number | -| ↳ `type` | string | Contact type \(Personal, Work, Other\) | -| ↳ `isPrimary` | boolean | Whether this is the primary phone | -| ↳ `createdAt` | string | ISO 8601 creation timestamp | -| ↳ `updatedAt` | string | ISO 8601 last update timestamp | ### `ashby_update_candidate` @@ -758,26 +991,36 @@ Updates an existing candidate record in Ashby. Only provided fields are changed. | Parameter | Type | Description | | --------- | ---- | ----------- | -| `id` | string | Candidate UUID | -| `name` | string | Full name | -| `primaryEmailAddress` | object | Primary email contact info | -| ↳ `value` | string | Email address | -| ↳ `type` | string | Contact type \(Personal, Work, Other\) | -| ↳ `isPrimary` | boolean | Whether this is the primary email | -| `primaryPhoneNumber` | object | Primary phone contact info | -| ↳ `value` | string | Phone number | -| ↳ `type` | string | Contact type \(Personal, Work, Other\) | -| ↳ `isPrimary` | boolean | Whether this is the primary phone | -| `profileUrl` | string | URL to the candidate Ashby profile | -| `position` | string | Current position or title | -| `company` | string | Current company | -| `linkedInUrl` | string | LinkedIn profile URL | -| `githubUrl` | string | GitHub profile URL | -| `tags` | array | Tags applied to the candidate | -| ↳ `id` | string | Tag UUID | -| ↳ `title` | string | Tag title | -| `applicationIds` | array | IDs of associated applications | +| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) | +| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion, compensation with compensationTiers\[\]\) | +| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) | +| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) | +| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | +| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | +| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | +| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) | +| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | +| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | +| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | +| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) | +| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId\) | +| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) | +| `tags` | json | List of candidate tags \(id, title, isArchived\) | +| `id` | string | Resource UUID | +| `name` | string | Resource name | +| `title` | string | Job title or job posting title | +| `status` | string | Status | +| `candidate` | json | Candidate details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) | +| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, createdAt, updatedAt\) | +| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) | +| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) | +| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) | +| `content` | string | Note content | +| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) | +| `isPrivate` | boolean | Whether the note is private | | `createdAt` | string | ISO 8601 creation timestamp | -| `updatedAt` | string | ISO 8601 last update timestamp | +| `moreDataAvailable` | boolean | Whether more pages exist | +| `nextCursor` | string | Pagination cursor for next page | +| `syncToken` | string | Sync token for incremental updates | diff --git a/apps/docs/content/docs/en/triggers/ashby.mdx b/apps/docs/content/docs/en/triggers/ashby.mdx index aad15714728..b4f557d05f3 100644 --- a/apps/docs/content/docs/en/triggers/ashby.mdx +++ b/apps/docs/content/docs/en/triggers/ashby.mdx @@ -97,6 +97,14 @@ Trigger workflow when a candidate is hired | ↳ `job` | object | job output from the tool | | ↳ `id` | string | Job UUID | | ↳ `title` | string | Job title | +| `offer` | object | offer output from the tool | +| ↳ `id` | string | Accepted offer UUID | +| ↳ `applicationId` | string | Associated application UUID | +| ↳ `acceptanceStatus` | string | Offer acceptance status | +| ↳ `offerStatus` | string | Offer process status | +| ↳ `decidedAt` | string | Offer decision timestamp \(ISO 8601\) | +| ↳ `latestVersion` | object | latestVersion output from the tool | +| ↳ `id` | string | Latest offer version UUID | --- diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index 42cc823ea18..360f103bbcd 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -1031,7 +1031,7 @@ }, { "name": "List Applications", - "description": "Lists all applications in an Ashby organization with pagination and optional filters for status, job, candidate, and creation date." + "description": "Lists all applications in an Ashby organization with pagination and optional filters for status, job, and creation date." }, { "name": "Get Application", @@ -1051,11 +1051,11 @@ }, { "name": "Add Candidate Tag", - "description": "Adds a tag to a candidate in Ashby." + "description": "Adds a tag to a candidate in Ashby and returns the updated candidate." }, { "name": "Remove Candidate Tag", - "description": "Removes a tag from a candidate in Ashby." + "description": "Removes a tag from a candidate in Ashby and returns the updated candidate." }, { "name": "Get Offer", diff --git a/apps/sim/blocks/blocks/ashby.ts b/apps/sim/blocks/blocks/ashby.ts index 0659dcbeda3..1113c6f6983 100644 --- a/apps/sim/blocks/blocks/ashby.ts +++ b/apps/sim/blocks/blocks/ashby.ts @@ -113,7 +113,6 @@ export const AshbyBlock: BlockConfig = { id: 'email', title: 'Email', type: 'short-input', - required: { field: 'operation', value: 'create_candidate' }, placeholder: 'Email address', condition: { field: 'operation', value: ['create_candidate', 'update_candidate'] }, }, @@ -308,14 +307,6 @@ Output only the ISO 8601 timestamp string, nothing else.`, condition: { field: 'operation', value: 'list_applications' }, mode: 'advanced', }, - { - id: 'filterCandidateId', - title: 'Candidate ID Filter', - type: 'short-input', - placeholder: 'Filter by candidate UUID', - condition: { field: 'operation', value: 'list_applications' }, - mode: 'advanced', - }, { id: 'createdAfter', title: 'Created After', @@ -366,6 +357,7 @@ Output only the ISO 8601 timestamp string, nothing else.`, 'list_openings', 'list_users', 'list_interviews', + 'list_candidate_tags', ], }, mode: 'advanced', @@ -386,10 +378,43 @@ Output only the ISO 8601 timestamp string, nothing else.`, 'list_openings', 'list_users', 'list_interviews', + 'list_candidate_tags', ], }, mode: 'advanced', }, + { + id: 'syncToken', + title: 'Sync Token', + type: 'short-input', + placeholder: 'Sync token for incremental updates', + condition: { field: 'operation', value: 'list_candidate_tags' }, + mode: 'advanced', + }, + { + id: 'includeArchived', + title: 'Include Archived', + type: 'switch', + condition: { + field: 'operation', + value: ['list_candidate_tags', 'list_archive_reasons'], + }, + mode: 'advanced', + }, + { + id: 'expandApplicationFormDefinition', + title: 'Include Application Form Definition', + type: 'switch', + condition: { field: 'operation', value: 'get_job_posting' }, + mode: 'advanced', + }, + { + id: 'expandSurveyFormDefinitions', + title: 'Include Survey Form Definitions', + type: 'switch', + condition: { field: 'operation', value: 'get_job_posting' }, + mode: 'advanced', + }, { id: 'tagId', title: 'Tag ID', @@ -476,11 +501,25 @@ Output only the ISO 8601 timestamp string, nothing else.`, if (params.searchEmail) result.email = params.searchEmail if (params.filterStatus) result.status = params.filterStatus if (params.filterJobId) result.jobId = params.filterJobId - if (params.filterCandidateId) result.candidateId = params.filterCandidateId if (params.jobStatus) result.status = params.jobStatus if (params.sendNotifications === 'true' || params.sendNotifications === true) { result.sendNotifications = true } + if (params.includeArchived === 'true' || params.includeArchived === true) { + result.includeArchived = true + } + if ( + params.expandApplicationFormDefinition === 'true' || + params.expandApplicationFormDefinition === true + ) { + result.expandApplicationFormDefinition = true + } + if ( + params.expandSurveyFormDefinitions === 'true' || + params.expandSurveyFormDefinitions === true + ) { + result.expandSurveyFormDefinitions = true + } if (params.appCandidateId) result.candidateId = params.appCandidateId if (params.appCreatedAt) result.createdAt = params.appCreatedAt if (params.updateName) result.name = params.updateName @@ -515,11 +554,20 @@ Output only the ISO 8601 timestamp string, nothing else.`, sendNotifications: { type: 'boolean', description: 'Send notifications' }, filterStatus: { type: 'string', description: 'Application status filter' }, filterJobId: { type: 'string', description: 'Job UUID filter' }, - filterCandidateId: { type: 'string', description: 'Candidate UUID filter' }, createdAfter: { type: 'string', description: 'Filter by creation date' }, jobStatus: { type: 'string', description: 'Job status filter' }, cursor: { type: 'string', description: 'Pagination cursor' }, perPage: { type: 'number', description: 'Results per page' }, + syncToken: { type: 'string', description: 'Sync token for incremental updates' }, + includeArchived: { type: 'boolean', description: 'Include archived records' }, + expandApplicationFormDefinition: { + type: 'boolean', + description: 'Include application form definition in job posting', + }, + expandSurveyFormDefinitions: { + type: 'boolean', + description: 'Include survey form definitions in job posting', + }, tagId: { type: 'string', description: 'Tag UUID' }, offerId: { type: 'string', description: 'Offer UUID' }, jobPostingId: { type: 'string', description: 'Job posting UUID' }, @@ -530,93 +578,113 @@ Output only the ISO 8601 timestamp string, nothing else.`, candidates: { type: 'json', description: - 'List of candidates (id, name, primaryEmailAddress, primaryPhoneNumber, createdAt, updatedAt)', + 'List of candidates with rich fields (id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses[], phoneNumbers[], socialLinks[], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents[], tags[], applicationIds[], customFields[], resumeFileHandle, fileHandles[], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt)', }, jobs: { type: 'json', description: - 'List of jobs (id, title, status, employmentType, departmentId, locationId, createdAt, updatedAt)', + 'List of jobs (id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds[], customFields[], jobPostingIds[], customRequisitionId, brandId, hiringTeam[], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings[] with latestVersion, compensation with compensationTiers[])', }, applications: { type: 'json', description: - 'List of applications (id, status, candidate, job, currentInterviewStage, source, createdAt, updatedAt)', + 'List of applications (id, status, customFields[], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields[], archivedAt, job summary, creditedToUser, hiringTeam[], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt)', }, notes: { type: 'json', - description: 'List of notes (id, content, author, createdAt)', + description: 'List of notes (id, content, author, isPrivate, createdAt)', }, offers: { type: 'json', description: - 'List of offers (id, offerStatus, acceptanceStatus, applicationId, startDate, salary, openingId)', + 'List of offers (id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields[]/fileHandles[]/author/approvalStatus)', }, archiveReasons: { type: 'json', - description: 'List of archive reasons (id, text, reasonType, isArchived)', + description: + 'List of archive reasons (id, text, reasonType [RejectedByCandidate/RejectedByOrg/Other], isArchived)', }, sources: { type: 'json', - description: 'List of sources (id, title, isArchived)', + description: 'List of sources (id, title, isArchived, sourceType {id, title, isArchived})', }, customFields: { type: 'json', - description: 'List of custom fields (id, title, fieldType, objectType, isArchived)', + description: + 'List of custom field definitions (id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues[] {label, value, isArchived})', }, departments: { type: 'json', - description: 'List of departments (id, name, isArchived, parentId)', + description: + 'List of departments (id, name, externalName, isArchived, parentId, createdAt, updatedAt)', }, locations: { type: 'json', - description: 'List of locations (id, name, isArchived, isRemote, address)', + description: + 'List of locations (id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress)', }, jobPostings: { type: 'json', description: - 'List of job postings (id, title, jobId, locationName, departmentName, employmentType, isListed, publishedDate)', + 'List of job postings (id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt)', }, openings: { type: 'json', - description: 'List of openings (id, openingState, isArchived, openedAt, closedAt)', + description: + 'List of openings (id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds[]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds[]/hiringTeam[]/customFields[])', }, users: { type: 'json', - description: 'List of users (id, firstName, lastName, email, isEnabled, globalRole)', + description: + 'List of users (id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId)', }, interviewSchedules: { type: 'json', description: - 'List of interview schedules (id, applicationId, interviewStageId, status, createdAt)', + 'List of interview schedules (id, applicationId, interviewStageId, interviewEvents[] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt)', }, tags: { type: 'json', description: 'List of candidate tags (id, title, isArchived)', }, - stageId: { type: 'string', description: 'Interview stage UUID after stage change' }, - success: { type: 'boolean', description: 'Whether the operation succeeded' }, - offerStatus: { - type: 'string', - description: 'Offer status (e.g. WaitingOnCandidateResponse, CandidateAccepted)', + id: { type: 'string', description: 'Resource UUID' }, + name: { type: 'string', description: 'Resource name' }, + title: { type: 'string', description: 'Job title or job posting title' }, + status: { type: 'string', description: 'Status' }, + candidate: { + type: 'json', + description: + 'Candidate details (id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses[], phoneNumbers[], socialLinks[], customFields[], source, creditedToUser, createdAt, updatedAt)', + }, + job: { + type: 'json', + description: + 'Job details (id, title, status, employmentType, locationId, departmentId, hiringTeam[], author, location, openings[], compensation, createdAt, updatedAt)', }, - acceptanceStatus: { - type: 'string', - description: 'Acceptance status (e.g. Accepted, Declined, Pending)', + application: { + type: 'json', + description: + 'Application details (id, status, customFields[], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam[], createdAt, updatedAt)', }, - applicationId: { type: 'string', description: 'Associated application UUID' }, - openingId: { type: 'string', description: 'Opening UUID associated with the offer' }, - salary: { + offer: { type: 'json', - description: 'Salary details from latest version (currencyCode, value)', + description: + 'Offer details (id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion)', + }, + jobPosting: { + type: 'json', + description: + 'Job posting details (id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy[], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt)', }, - startDate: { type: 'string', description: 'Offer start date from latest version' }, - id: { type: 'string', description: 'Resource UUID' }, - name: { type: 'string', description: 'Resource name' }, - title: { type: 'string', description: 'Job title' }, - status: { type: 'string', description: 'Status' }, - noteId: { type: 'string', description: 'Created note UUID' }, content: { type: 'string', description: 'Note content' }, + author: { + type: 'json', + description: 'Note author (id, firstName, lastName, email, globalRole, isEnabled)', + }, + isPrivate: { type: 'boolean', description: 'Whether the note is private' }, + createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' }, moreDataAvailable: { type: 'boolean', description: 'Whether more pages exist' }, nextCursor: { type: 'string', description: 'Pagination cursor for next page' }, + syncToken: { type: 'string', description: 'Sync token for incremental updates' }, }, } diff --git a/apps/sim/lib/webhooks/providers/ashby.ts b/apps/sim/lib/webhooks/providers/ashby.ts index 0580a899b8f..f75fa58b00a 100644 --- a/apps/sim/lib/webhooks/providers/ashby.ts +++ b/apps/sim/lib/webhooks/providers/ashby.ts @@ -2,16 +2,18 @@ import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' import { hmacSha256Hex } from '@sim/security/hmac' import { generateId } from '@sim/utils/id' +import { NextResponse } from 'next/server' import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { + AuthContext, DeleteSubscriptionContext, + EventMatchContext, FormatInputContext, FormatInputResult, SubscriptionContext, SubscriptionResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' -import { createHmacVerifier } from '@/lib/webhooks/providers/utils' const logger = createLogger('WebhookProvider:Ashby') @@ -48,17 +50,74 @@ export const ashbyHandler: WebhookProviderHandler = { input: { ...((b.data as Record) || {}), action: b.action, - data: b.data || {}, }, } }, - verifyAuth: createHmacVerifier({ - configKey: 'secretToken', - headerName: 'ashby-signature', - validateFn: validateAshbySignature, - providerLabel: 'Ashby', - }), + verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext): NextResponse | null { + const secretToken = (providerConfig.secretToken as string | undefined)?.trim() + if (!secretToken) { + logger.warn( + `[${requestId}] Ashby webhook missing secretToken in providerConfig — rejecting request` + ) + return new NextResponse( + 'Unauthorized - Ashby webhook signing secret is not configured. Re-save the trigger so a webhook can be registered.', + { status: 401 } + ) + } + + const signature = request.headers.get('ashby-signature') + if (!signature) { + logger.warn(`[${requestId}] Ashby webhook missing signature header`) + return new NextResponse('Unauthorized - Missing Ashby signature', { status: 401 }) + } + + if (!validateAshbySignature(secretToken, signature, rawBody)) { + logger.warn(`[${requestId}] Ashby signature verification failed`, { + signatureLength: signature.length, + secretLength: secretToken.length, + }) + return new NextResponse('Unauthorized - Invalid Ashby signature', { status: 401 }) + } + + return null + }, + + async matchEvent({ + webhook, + body, + requestId, + providerConfig, + }: EventMatchContext): Promise { + const triggerId = providerConfig.triggerId as string | undefined + const obj = body as Record + const action = typeof obj?.action === 'string' ? obj.action : '' + + if (action === 'ping') { + logger.debug(`[${requestId}] Ashby ping event received. Skipping execution.`, { + webhookId: webhook.id, + triggerId, + }) + return false + } + + if (!triggerId) return true + + const { isAshbyEventMatch } = await import('@/triggers/ashby/utils') + if (!isAshbyEventMatch(triggerId, action)) { + logger.debug( + `[${requestId}] Ashby event mismatch for trigger ${triggerId}. Action: ${action || '(missing)'}. Skipping execution.`, + { + webhookId: webhook.id, + triggerId, + receivedAction: action, + } + ) + return false + } + + return true + }, async createSubscription(ctx: SubscriptionContext): Promise { try { @@ -78,18 +137,12 @@ export const ashbyHandler: WebhookProviderHandler = { throw new Error('Trigger ID is required to create Ashby webhook.') } - const webhookTypeMap: Record = { - ashby_application_submit: 'applicationSubmit', - ashby_candidate_stage_change: 'candidateStageChange', - ashby_candidate_hire: 'candidateHire', - ashby_candidate_delete: 'candidateDelete', - ashby_job_create: 'jobCreate', - ashby_offer_create: 'offerCreate', - } - - const webhookType = webhookTypeMap[triggerId] + const { ASHBY_TRIGGER_ACTION_MAP } = await import('@/triggers/ashby/utils') + const webhookType = ASHBY_TRIGGER_ACTION_MAP[triggerId] if (!webhookType) { - throw new Error(`Unknown Ashby triggerId: ${triggerId}. Add it to webhookTypeMap.`) + throw new Error( + `Unknown Ashby triggerId: ${triggerId}. Add it to ASHBY_TRIGGER_ACTION_MAP.` + ) } const notificationUrl = getNotificationUrl(ctx.webhook) diff --git a/apps/sim/lib/workflows/migrations/subblock-migrations.ts b/apps/sim/lib/workflows/migrations/subblock-migrations.ts index bc6ebdd465f..8161b7d2432 100644 --- a/apps/sim/lib/workflows/migrations/subblock-migrations.ts +++ b/apps/sim/lib/workflows/migrations/subblock-migrations.ts @@ -34,6 +34,7 @@ export const SUBBLOCK_ID_MIGRATIONS: Record> = { ashby: { emailType: '_removed_emailType', phoneType: '_removed_phoneType', + filterCandidateId: '_removed_filterCandidateId', }, rippling: { action: '_removed_action', diff --git a/apps/sim/tools/ashby/add_candidate_tag.ts b/apps/sim/tools/ashby/add_candidate_tag.ts index 35120a5802d..e013cf63be1 100644 --- a/apps/sim/tools/ashby/add_candidate_tag.ts +++ b/apps/sim/tools/ashby/add_candidate_tag.ts @@ -1,3 +1,5 @@ +import type { AshbyCandidate } from '@/tools/ashby/types' +import { CANDIDATE_OUTPUTS, mapCandidate } from '@/tools/ashby/utils' import type { ToolConfig, ToolResponse } from '@/tools/types' interface AshbyAddCandidateTagParams { @@ -7,9 +9,7 @@ interface AshbyAddCandidateTagParams { } interface AshbyAddCandidateTagResponse extends ToolResponse { - output: { - success: boolean - } + output: AshbyCandidate } export const addCandidateTagTool: ToolConfig< @@ -18,7 +18,7 @@ export const addCandidateTagTool: ToolConfig< > = { id: 'ashby_add_candidate_tag', name: 'Ashby Add Candidate Tag', - description: 'Adds a tag to a candidate in Ashby.', + description: 'Adds a tag to a candidate in Ashby and returns the updated candidate.', version: '1.0.0', params: { @@ -50,8 +50,8 @@ export const addCandidateTagTool: ToolConfig< Authorization: `Basic ${btoa(`${params.apiKey}:`)}`, }), body: (params) => ({ - candidateId: params.candidateId, - tagId: params.tagId, + candidateId: params.candidateId.trim(), + tagId: params.tagId.trim(), }), }, @@ -64,13 +64,9 @@ export const addCandidateTagTool: ToolConfig< return { success: true, - output: { - success: true, - }, + output: mapCandidate(data.results), } }, - outputs: { - success: { type: 'boolean', description: 'Whether the tag was successfully added' }, - }, + outputs: CANDIDATE_OUTPUTS, } diff --git a/apps/sim/tools/ashby/change_application_stage.ts b/apps/sim/tools/ashby/change_application_stage.ts index 6de88e6cd8f..c573b04df3e 100644 --- a/apps/sim/tools/ashby/change_application_stage.ts +++ b/apps/sim/tools/ashby/change_application_stage.ts @@ -1,3 +1,5 @@ +import type { AshbyApplication } from '@/tools/ashby/types' +import { APPLICATION_OUTPUTS, mapApplication } from '@/tools/ashby/utils' import type { ToolConfig, ToolResponse } from '@/tools/types' interface AshbyChangeApplicationStageParams { @@ -8,10 +10,7 @@ interface AshbyChangeApplicationStageParams { } interface AshbyChangeApplicationStageResponse extends ToolResponse { - output: { - applicationId: string - stageId: string | null - } + output: AshbyApplication } export const changeApplicationStageTool: ToolConfig< @@ -61,10 +60,10 @@ export const changeApplicationStageTool: ToolConfig< }), body: (params) => { const body: Record = { - applicationId: params.applicationId, - interviewStageId: params.interviewStageId, + applicationId: params.applicationId.trim(), + interviewStageId: params.interviewStageId.trim(), } - if (params.archiveReasonId) body.archiveReasonId = params.archiveReasonId + if (params.archiveReasonId) body.archiveReasonId = params.archiveReasonId.trim() return body }, }, @@ -76,19 +75,11 @@ export const changeApplicationStageTool: ToolConfig< throw new Error(data.errorInfo?.message || 'Failed to change application stage') } - const r = data.results - return { success: true, - output: { - applicationId: r.id ?? null, - stageId: r.currentInterviewStage?.id ?? null, - }, + output: mapApplication(data.results), } }, - outputs: { - applicationId: { type: 'string', description: 'Application UUID' }, - stageId: { type: 'string', description: 'New interview stage UUID' }, - }, + outputs: APPLICATION_OUTPUTS, } diff --git a/apps/sim/tools/ashby/create_application.ts b/apps/sim/tools/ashby/create_application.ts index b4bff1e5203..5482446de7f 100644 --- a/apps/sim/tools/ashby/create_application.ts +++ b/apps/sim/tools/ashby/create_application.ts @@ -1,3 +1,5 @@ +import type { AshbyApplication } from '@/tools/ashby/types' +import { APPLICATION_OUTPUTS, mapApplication } from '@/tools/ashby/utils' import type { ToolConfig, ToolResponse } from '@/tools/types' interface AshbyCreateApplicationParams { @@ -12,9 +14,7 @@ interface AshbyCreateApplicationParams { } interface AshbyCreateApplicationResponse extends ToolResponse { - output: { - applicationId: string - } + output: AshbyApplication } export const createApplicationTool: ToolConfig< @@ -88,13 +88,13 @@ export const createApplicationTool: ToolConfig< }), body: (params) => { const body: Record = { - candidateId: params.candidateId, - jobId: params.jobId, + candidateId: params.candidateId.trim(), + jobId: params.jobId.trim(), } - if (params.interviewPlanId) body.interviewPlanId = params.interviewPlanId - if (params.interviewStageId) body.interviewStageId = params.interviewStageId - if (params.sourceId) body.sourceId = params.sourceId - if (params.creditedToUserId) body.creditedToUserId = params.creditedToUserId + if (params.interviewPlanId) body.interviewPlanId = params.interviewPlanId.trim() + if (params.interviewStageId) body.interviewStageId = params.interviewStageId.trim() + if (params.sourceId) body.sourceId = params.sourceId.trim() + if (params.creditedToUserId) body.creditedToUserId = params.creditedToUserId.trim() if (params.createdAt) body.createdAt = params.createdAt return body }, @@ -107,17 +107,11 @@ export const createApplicationTool: ToolConfig< throw new Error(data.errorInfo?.message || 'Failed to create application') } - const r = data.results - return { success: true, - output: { - applicationId: r.applicationId ?? null, - }, + output: mapApplication(data.results), } }, - outputs: { - applicationId: { type: 'string', description: 'Created application UUID' }, - }, + outputs: APPLICATION_OUTPUTS, } diff --git a/apps/sim/tools/ashby/create_candidate.ts b/apps/sim/tools/ashby/create_candidate.ts index cda7bb40aaf..49d58342a3e 100644 --- a/apps/sim/tools/ashby/create_candidate.ts +++ b/apps/sim/tools/ashby/create_candidate.ts @@ -1,5 +1,6 @@ +import type { AshbyCreateCandidateParams, AshbyCreateCandidateResponse } from '@/tools/ashby/types' +import { CANDIDATE_OUTPUTS, mapCandidate } from '@/tools/ashby/utils' import type { ToolConfig } from '@/tools/types' -import type { AshbyCreateCandidateParams, AshbyCreateCandidateResponse } from './types' export const createCandidateTool: ToolConfig< AshbyCreateCandidateParams, @@ -25,7 +26,7 @@ export const createCandidateTool: ToolConfig< }, email: { type: 'string', - required: true, + required: false, visibility: 'user-or-llm', description: 'Primary email address for the candidate', }, @@ -65,12 +66,12 @@ export const createCandidateTool: ToolConfig< body: (params) => { const body: Record = { name: params.name, - email: params.email, } + if (params.email) body.email = params.email if (params.phoneNumber) body.phoneNumber = params.phoneNumber if (params.linkedInUrl) body.linkedInUrl = params.linkedInUrl if (params.githubUrl) body.githubUrl = params.githubUrl - if (params.sourceId) body.sourceId = params.sourceId + if (params.sourceId) body.sourceId = params.sourceId.trim() return body }, }, @@ -82,55 +83,11 @@ export const createCandidateTool: ToolConfig< throw new Error(data.errorInfo?.message || 'Failed to create candidate') } - const r = data.results - return { success: true, - output: { - id: r.id ?? null, - name: r.name ?? null, - primaryEmailAddress: r.primaryEmailAddress - ? { - value: r.primaryEmailAddress.value ?? '', - type: r.primaryEmailAddress.type ?? 'Other', - isPrimary: r.primaryEmailAddress.isPrimary ?? true, - } - : null, - primaryPhoneNumber: r.primaryPhoneNumber - ? { - value: r.primaryPhoneNumber.value ?? '', - type: r.primaryPhoneNumber.type ?? 'Other', - isPrimary: r.primaryPhoneNumber.isPrimary ?? true, - } - : null, - createdAt: r.createdAt ?? null, - }, + output: mapCandidate(data.results), } }, - outputs: { - id: { type: 'string', description: 'Created candidate UUID' }, - name: { type: 'string', description: 'Full name' }, - primaryEmailAddress: { - type: 'object', - description: 'Primary email contact info', - optional: true, - properties: { - value: { type: 'string', description: 'Email address' }, - type: { type: 'string', description: 'Contact type (Personal, Work, Other)' }, - isPrimary: { type: 'boolean', description: 'Whether this is the primary email' }, - }, - }, - primaryPhoneNumber: { - type: 'object', - description: 'Primary phone contact info', - optional: true, - properties: { - value: { type: 'string', description: 'Phone number' }, - type: { type: 'string', description: 'Contact type (Personal, Work, Other)' }, - isPrimary: { type: 'boolean', description: 'Whether this is the primary phone' }, - }, - }, - createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' }, - }, + outputs: CANDIDATE_OUTPUTS, } diff --git a/apps/sim/tools/ashby/create_note.ts b/apps/sim/tools/ashby/create_note.ts index 594efb24d91..03e1ec15546 100644 --- a/apps/sim/tools/ashby/create_note.ts +++ b/apps/sim/tools/ashby/create_note.ts @@ -1,5 +1,5 @@ +import type { AshbyCreateNoteParams, AshbyCreateNoteResponse } from '@/tools/ashby/types' import type { ToolConfig } from '@/tools/types' -import type { AshbyCreateNoteParams, AshbyCreateNoteResponse } from './types' export const createNoteTool: ToolConfig = { id: 'ashby_create_note', @@ -51,7 +51,7 @@ export const createNoteTool: ToolConfig { const body: Record = { - candidateId: params.candidateId, + candidateId: params.candidateId.trim(), sendNotifications: params.sendNotifications ?? false, } if (params.noteType === 'text/html') { @@ -74,16 +74,42 @@ export const createNoteTool: ToolConfig ({ - applicationId: params.applicationId, + applicationId: params.applicationId.trim(), }), }, @@ -80,98 +54,11 @@ export const getApplicationTool: ToolConfig< throw new Error(data.errorInfo?.message || 'Failed to get application') } - const r = data.results - return { success: true, - output: { - id: r.id ?? null, - status: r.status ?? null, - candidate: { - id: r.candidate?.id ?? null, - name: r.candidate?.name ?? null, - }, - job: { - id: r.job?.id ?? null, - title: r.job?.title ?? null, - }, - currentInterviewStage: r.currentInterviewStage - ? { - id: r.currentInterviewStage.id ?? null, - title: r.currentInterviewStage.title ?? null, - type: r.currentInterviewStage.type ?? null, - } - : null, - source: r.source - ? { - id: r.source.id ?? null, - title: r.source.title ?? null, - } - : null, - archiveReason: r.archiveReason - ? { - id: r.archiveReason.id ?? null, - text: r.archiveReason.text ?? null, - reasonType: r.archiveReason.reasonType ?? null, - } - : null, - archivedAt: r.archivedAt ?? null, - createdAt: r.createdAt ?? null, - updatedAt: r.updatedAt ?? null, - }, + output: mapApplication(data.results), } }, - outputs: { - id: { type: 'string', description: 'Application UUID' }, - status: { type: 'string', description: 'Application status (Active, Hired, Archived, Lead)' }, - candidate: { - type: 'object', - description: 'Associated candidate', - properties: { - id: { type: 'string', description: 'Candidate UUID' }, - name: { type: 'string', description: 'Candidate name' }, - }, - }, - job: { - type: 'object', - description: 'Associated job', - properties: { - id: { type: 'string', description: 'Job UUID' }, - title: { type: 'string', description: 'Job title' }, - }, - }, - currentInterviewStage: { - type: 'object', - description: 'Current interview stage', - optional: true, - properties: { - id: { type: 'string', description: 'Stage UUID' }, - title: { type: 'string', description: 'Stage title' }, - type: { type: 'string', description: 'Stage type' }, - }, - }, - source: { - type: 'object', - description: 'Application source', - optional: true, - properties: { - id: { type: 'string', description: 'Source UUID' }, - title: { type: 'string', description: 'Source title' }, - }, - }, - archiveReason: { - type: 'object', - description: 'Reason for archival', - optional: true, - properties: { - id: { type: 'string', description: 'Reason UUID' }, - text: { type: 'string', description: 'Reason text' }, - reasonType: { type: 'string', description: 'Reason type' }, - }, - }, - archivedAt: { type: 'string', description: 'ISO 8601 archive timestamp', optional: true }, - createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' }, - updatedAt: { type: 'string', description: 'ISO 8601 last update timestamp' }, - }, + outputs: APPLICATION_OUTPUTS, } diff --git a/apps/sim/tools/ashby/get_candidate.ts b/apps/sim/tools/ashby/get_candidate.ts index 6fcbe86d259..c6aed78aa4e 100644 --- a/apps/sim/tools/ashby/get_candidate.ts +++ b/apps/sim/tools/ashby/get_candidate.ts @@ -1,5 +1,6 @@ +import type { AshbyGetCandidateParams, AshbyGetCandidateResponse } from '@/tools/ashby/types' +import { CANDIDATE_OUTPUTS, mapCandidate } from '@/tools/ashby/utils' import type { ToolConfig } from '@/tools/types' -import type { AshbyGetCandidateParams, AshbyGetCandidateResponse } from './types' export const getCandidateTool: ToolConfig = { id: 'ashby_get_candidate', @@ -30,7 +31,7 @@ export const getCandidateTool: ToolConfig ({ - candidateId: params.candidateId.trim(), + id: params.candidateId.trim(), }), }, @@ -41,94 +42,11 @@ export const getCandidateTool: ToolConfig l.type === 'LinkedIn')?.url ?? null, - githubUrl: - (r.socialLinks ?? []).find((l: { type: string }) => l.type === 'GitHub')?.url ?? null, - tags: (r.tags ?? []).map((t: { id: string; title: string }) => ({ - id: t.id, - title: t.title, - })), - applicationIds: r.applicationIds ?? [], - createdAt: r.createdAt ?? null, - updatedAt: r.updatedAt ?? null, - }, + output: mapCandidate(data.results), } }, - outputs: { - id: { type: 'string', description: 'Candidate UUID' }, - name: { type: 'string', description: 'Full name' }, - primaryEmailAddress: { - type: 'object', - description: 'Primary email contact info', - optional: true, - properties: { - value: { type: 'string', description: 'Email address' }, - type: { type: 'string', description: 'Contact type (Personal, Work, Other)' }, - isPrimary: { type: 'boolean', description: 'Whether this is the primary email' }, - }, - }, - primaryPhoneNumber: { - type: 'object', - description: 'Primary phone contact info', - optional: true, - properties: { - value: { type: 'string', description: 'Phone number' }, - type: { type: 'string', description: 'Contact type (Personal, Work, Other)' }, - isPrimary: { type: 'boolean', description: 'Whether this is the primary phone' }, - }, - }, - profileUrl: { - type: 'string', - description: 'URL to the candidate Ashby profile', - optional: true, - }, - position: { type: 'string', description: 'Current position or title', optional: true }, - company: { type: 'string', description: 'Current company', optional: true }, - linkedInUrl: { type: 'string', description: 'LinkedIn profile URL', optional: true }, - githubUrl: { type: 'string', description: 'GitHub profile URL', optional: true }, - tags: { - type: 'array', - description: 'Tags applied to the candidate', - items: { - type: 'object', - properties: { - id: { type: 'string', description: 'Tag UUID' }, - title: { type: 'string', description: 'Tag title' }, - }, - }, - }, - applicationIds: { - type: 'array', - description: 'IDs of associated applications', - items: { type: 'string', description: 'Application UUID' }, - }, - createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' }, - updatedAt: { type: 'string', description: 'ISO 8601 last update timestamp' }, - }, + outputs: CANDIDATE_OUTPUTS, } diff --git a/apps/sim/tools/ashby/get_job.ts b/apps/sim/tools/ashby/get_job.ts index 68b0827b3c3..03a7b1c9087 100644 --- a/apps/sim/tools/ashby/get_job.ts +++ b/apps/sim/tools/ashby/get_job.ts @@ -1,5 +1,6 @@ +import type { AshbyGetJobParams, AshbyGetJobResponse } from '@/tools/ashby/types' +import { JOB_OUTPUTS, mapJob } from '@/tools/ashby/utils' import type { ToolConfig } from '@/tools/types' -import type { AshbyGetJobParams, AshbyGetJobResponse } from './types' export const getJobTool: ToolConfig = { id: 'ashby_get_job', @@ -30,7 +31,7 @@ export const getJobTool: ToolConfig = { Authorization: `Basic ${btoa(`${params.apiKey}:`)}`, }), body: (params) => ({ - jobId: params.jobId.trim(), + id: params.jobId.trim(), }), }, @@ -41,43 +42,11 @@ export const getJobTool: ToolConfig = { throw new Error(data.errorInfo?.message || 'Failed to get job') } - const r = data.results - return { success: true, - output: { - id: r.id ?? null, - title: r.title ?? null, - status: r.status ?? null, - employmentType: r.employmentType ?? null, - departmentId: r.departmentId ?? null, - locationId: r.locationId ?? null, - descriptionPlain: r.descriptionPlain ?? null, - isArchived: r.isArchived ?? false, - createdAt: r.createdAt ?? null, - updatedAt: r.updatedAt ?? null, - }, + output: mapJob(data.results), } }, - outputs: { - id: { type: 'string', description: 'Job UUID' }, - title: { type: 'string', description: 'Job title' }, - status: { type: 'string', description: 'Job status (Open, Closed, Draft, Archived)' }, - employmentType: { - type: 'string', - description: 'Employment type (FullTime, PartTime, Intern, Contract, Temporary)', - optional: true, - }, - departmentId: { type: 'string', description: 'Department UUID', optional: true }, - locationId: { type: 'string', description: 'Location UUID', optional: true }, - descriptionPlain: { - type: 'string', - description: 'Job description in plain text', - optional: true, - }, - isArchived: { type: 'boolean', description: 'Whether the job is archived' }, - createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' }, - updatedAt: { type: 'string', description: 'ISO 8601 last update timestamp' }, - }, + outputs: JOB_OUTPUTS, } diff --git a/apps/sim/tools/ashby/get_job_posting.ts b/apps/sim/tools/ashby/get_job_posting.ts index 14b39dfa34d..fc0d973c549 100644 --- a/apps/sim/tools/ashby/get_job_posting.ts +++ b/apps/sim/tools/ashby/get_job_posting.ts @@ -3,20 +3,80 @@ import type { ToolConfig, ToolResponse } from '@/tools/types' interface AshbyGetJobPostingParams { apiKey: string jobPostingId: string + expandApplicationFormDefinition?: boolean + expandSurveyFormDefinitions?: boolean +} + +interface AshbyDescriptionPart { + html: string | null + plain: string | null +} + +interface AshbyJobPosting { + id: string + title: string + descriptionPlain: string | null + descriptionHtml: string | null + descriptionSocial: string | null + descriptionParts: { + descriptionOpening: AshbyDescriptionPart | null + descriptionBody: AshbyDescriptionPart | null + descriptionClosing: AshbyDescriptionPart | null + } | null + departmentName: string | null + teamName: string | null + teamNameHierarchy: string[] + jobId: string | null + locationName: string | null + locationIds: { + primaryLocationId: string | null + secondaryLocationIds: string[] + } | null + address: { + postalAddress: { + addressCountry: string | null + addressRegion: string | null + addressLocality: string | null + postalCode: string | null + streetAddress: string | null + } | null + } | null + isRemote: boolean + workplaceType: string | null + employmentType: string | null + isListed: boolean + suppressDescriptionOpening: boolean + suppressDescriptionClosing: boolean + publishedDate: string | null + applicationDeadline: string | null + externalLink: string | null + applyLink: string | null + compensation: { + compensationTierSummary: string | null + summaryComponents: Array<{ + summary: string | null + compensationTypeLabel: string | null + interval: string | null + currencyCode: string | null + minValue: number | null + maxValue: number | null + }> + shouldDisplayCompensationOnJobBoard: boolean + } | null + applicationLimitCalloutHtml: string | null + updatedAt: string | null } interface AshbyGetJobPostingResponse extends ToolResponse { - output: { - id: string - title: string - jobId: string | null - locationName: string | null - departmentName: string | null - employmentType: string | null - descriptionPlain: string | null - isListed: boolean - publishedDate: string | null - externalLink: string | null + output: AshbyJobPosting +} + +function mapDescriptionPart(raw: unknown): AshbyDescriptionPart | null { + if (!raw || typeof raw !== 'object') return null + const p = raw as Record + return { + html: (p.html as string) ?? null, + plain: (p.plain as string) ?? null, } } @@ -39,6 +99,18 @@ export const getJobPostingTool: ToolConfig ({ - jobPostingId: params.jobPostingId, - }), + body: (params) => { + const body: Record = { + jobPostingId: params.jobPostingId.trim(), + } + if (params.expandApplicationFormDefinition !== undefined) { + body.expandApplicationFormDefinition = params.expandApplicationFormDefinition + } + if (params.expandSurveyFormDefinitions !== undefined) { + body.expandSurveyFormDefinitions = params.expandSurveyFormDefinitions + } + return body + }, }, transformResponse: async (response: Response) => { @@ -60,21 +141,90 @@ export const getJobPostingTool: ToolConfig & { + descriptionParts?: Record + locationIds?: { primaryLocationId?: string; secondaryLocationIds?: string[] } + address?: { postalAddress?: Record } + compensation?: Record & { + summaryComponents?: Array> + } + } + + const pa = r.address?.postalAddress + const comp = r.compensation + const summaryComponents = Array.isArray(comp?.summaryComponents) ? comp.summaryComponents : [] + const descParts = r.descriptionParts return { success: true, output: { - id: r.id ?? null, - title: r.jobTitle ?? r.title ?? null, - jobId: r.jobId ?? null, - locationName: r.locationName ?? null, - departmentName: r.departmentName ?? null, - employmentType: r.employmentType ?? null, - descriptionPlain: r.descriptionPlain ?? r.description ?? null, - isListed: r.isListed ?? false, - publishedDate: r.publishedDate ?? null, - externalLink: r.externalLink ?? null, + id: (r.id as string) ?? '', + title: (r.title as string) ?? '', + descriptionPlain: (r.descriptionPlain as string) ?? null, + descriptionHtml: (r.descriptionHtml as string) ?? null, + descriptionSocial: (r.descriptionSocial as string) ?? null, + descriptionParts: descParts + ? { + descriptionOpening: mapDescriptionPart(descParts.descriptionOpening), + descriptionBody: mapDescriptionPart(descParts.descriptionBody), + descriptionClosing: mapDescriptionPart(descParts.descriptionClosing), + } + : null, + departmentName: (r.departmentName as string) ?? null, + teamName: (r.teamName as string) ?? null, + teamNameHierarchy: Array.isArray(r.teamNameHierarchy) + ? (r.teamNameHierarchy as string[]) + : [], + jobId: (r.jobId as string) ?? null, + locationName: (r.locationName as string) ?? null, + locationIds: r.locationIds + ? { + primaryLocationId: r.locationIds.primaryLocationId ?? null, + secondaryLocationIds: Array.isArray(r.locationIds.secondaryLocationIds) + ? r.locationIds.secondaryLocationIds + : [], + } + : null, + address: r.address + ? { + postalAddress: pa + ? { + addressCountry: (pa.addressCountry as string) ?? null, + addressRegion: (pa.addressRegion as string) ?? null, + addressLocality: (pa.addressLocality as string) ?? null, + postalCode: (pa.postalCode as string) ?? null, + streetAddress: (pa.streetAddress as string) ?? null, + } + : null, + } + : null, + isRemote: (r.isRemote as boolean) ?? false, + workplaceType: (r.workplaceType as string) ?? null, + employmentType: (r.employmentType as string) ?? null, + isListed: (r.isListed as boolean) ?? false, + suppressDescriptionOpening: (r.suppressDescriptionOpening as boolean) ?? false, + suppressDescriptionClosing: (r.suppressDescriptionClosing as boolean) ?? false, + publishedDate: (r.publishedDate as string) ?? null, + applicationDeadline: (r.applicationDeadline as string) ?? null, + externalLink: (r.externalLink as string) ?? null, + applyLink: (r.applyLink as string) ?? null, + compensation: comp + ? { + compensationTierSummary: (comp.compensationTierSummary as string) ?? null, + summaryComponents: summaryComponents.map((c) => ({ + summary: (c.summary as string) ?? null, + compensationTypeLabel: (c.compensationTypeLabel as string) ?? null, + interval: (c.interval as string) ?? null, + currencyCode: (c.currencyCode as string) ?? null, + minValue: (c.minValue as number) ?? null, + maxValue: (c.maxValue as number) ?? null, + })), + shouldDisplayCompensationOnJobBoard: + (comp.shouldDisplayCompensationOnJobBoard as boolean) ?? false, + } + : null, + applicationLimitCalloutHtml: (r.applicationLimitCalloutHtml as string) ?? null, + updatedAt: (r.updatedAt as string) ?? null, }, } }, @@ -82,25 +232,188 @@ export const getJobPostingTool: ToolConfig