{isLoading && !hasMessages ? (
) : (
@@ -714,8 +736,15 @@ export function MothershipChat({
key={virtualItem.key}
data-index={index}
ref={virtualizer.measureElement}
- className='absolute top-0 left-0 w-full'
- style={{ transform: `translateY(${virtualItem.start}px)` }}
+ /* Positioned with a real `top`, NOT `top-0` + translateY:
+ text selection maps a drag's start point to a text
+ position via the rows' LAYOUT boxes, and with every row
+ laid out at y=0 a drag starting in the gutter anchors in
+ the wrong row — selections ran upward from a downward
+ drag. Transforms move paint and hit-testing but not the
+ layout box that mapping falls back to. */
+ className='absolute left-0 w-full'
+ style={{ top: virtualItem.start }}
>
{msg.role === 'user' ? (
interactionPairing.hiddenUserByIndex[index] ? null : (
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.test.ts
index 63c974fcbc..23b0947471 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.test.ts
@@ -30,6 +30,7 @@ import {
createBrowserPanelGeometryOcclusionLease,
hasNativeSurfaceOcclusion,
NATIVE_SURFACE_OCCLUSION_SELECTOR,
+ snapshotMatchesHost,
useBrowserPanelOcclusion,
} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion'
@@ -582,3 +583,44 @@ describe('useBrowserPanelOcclusion modal lifecycle', () => {
hook.unmount()
})
})
+
+describe('snapshotMatchesHost', () => {
+ const rect = (x: number, y: number, width: number, height: number) =>
+ ({ x, y, width, height }) as DOMRect
+
+ it('accepts a capture that still describes the host rect', () => {
+ expect(
+ snapshotMatchesHost(
+ { viewportBounds: { x: 10, y: 20, width: 800, height: 600 } },
+ rect(10, 20, 800, 600)
+ )
+ ).toBe(true)
+ })
+
+ it('tolerates sub-pixel drift from rounding', () => {
+ expect(
+ snapshotMatchesHost(
+ { viewportBounds: { x: 10, y: 20, width: 800, height: 600 } },
+ rect(10.4, 19.6, 800.5, 599.5)
+ )
+ ).toBe(true)
+ })
+
+ it('rejects a capture taken before a scroll lock reflowed the panel', () => {
+ // Modal scroll lock removes the scrollbar: the host widens by 15px, so the
+ // pre-lock capture would paint misaligned — the flash this guards.
+ expect(
+ snapshotMatchesHost(
+ { viewportBounds: { x: 10, y: 20, width: 800, height: 600 } },
+ rect(10, 20, 815, 600)
+ )
+ ).toBe(false)
+ })
+
+ it('accepts captures with no viewport bounds (host-tracking fallback style)', () => {
+ expect(snapshotMatchesHost({ viewportBounds: undefined }, rect(0, 0, 100, 100))).toBe(true)
+ expect(
+ snapshotMatchesHost({ viewportBounds: { x: 0, y: 0, width: 10, height: 10 } }, null)
+ ).toBe(true)
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.ts
index b4b76eb936..3f7acedff7 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.ts
@@ -184,10 +184,34 @@ async function decodeSnapshot(dataUrl: string): Promise
{
* changing layers never reveals or recaptures the native view, and the view is
* revealed only after the final reason disappears.
*/
+
+/** Largest tolerated drift, in CSS px, between a capture and the live host rect. */
+const SNAPSHOT_GEOMETRY_TOLERANCE_PX = 1
+
+/**
+ * Whether a captured frame still describes the rectangle the panel occupies.
+ * A capture with no viewport bounds is positioned by the fallback
+ * `absolute inset-0` style, which tracks the host by construction.
+ */
+export function snapshotMatchesHost(
+ frame: Pick,
+ hostRect: DOMRect | null
+): boolean {
+ const bounds = frame.viewportBounds
+ if (!bounds || !hostRect) return true
+ return (
+ Math.abs(bounds.x - hostRect.x) <= SNAPSHOT_GEOMETRY_TOLERANCE_PX &&
+ Math.abs(bounds.y - hostRect.y) <= SNAPSHOT_GEOMETRY_TOLERANCE_PX &&
+ Math.abs(bounds.width - hostRect.width) <= SNAPSHOT_GEOMETRY_TOLERANCE_PX &&
+ Math.abs(bounds.height - hostRect.height) <= SNAPSHOT_GEOMETRY_TOLERANCE_PX
+ )
+}
+
export function useBrowserPanelOcclusion(
scopeId: string,
activeTabId: string | null,
- panelVisible = true
+ panelVisible = true,
+ getHostRect?: () => DOMRect | null
): BrowserPanelOcclusion {
const [snapshotRender, setSnapshotRender] = useState(null)
const [activeOverlay, setActiveOverlay] = useState(null)
@@ -204,8 +228,10 @@ export function useBrowserPanelOcclusion(
const paintFramesRef = useRef([])
const reconcileChainRef = useRef>(Promise.resolve(true))
const mountedRef = useRef(true)
+ const getHostRectRef = useRef(getHostRect)
activeTabIdRef.current = activeTabId
panelVisibleRef.current = panelVisible
+ getHostRectRef.current = getHostRect
const updateSnapshotRender = useCallback((render: SnapshotRender | null) => {
snapshotRenderRef.current = render
@@ -303,7 +329,7 @@ export function useBrowserPanelOcclusion(
// Modal scroll locking can alter panel geometry between capture and the
// final native hide. One fresh capture retries that now-settled layout.
- const maxAttempts = desired === 'modal' ? 2 : 1
+ const maxAttempts = desired === 'modal' ? 3 : 1
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const frame = await captureBrowserPanelSnapshot(scopeId).catch(() => null)
if (!mountedRef.current || version !== transitionVersionRef.current) return false
@@ -318,6 +344,13 @@ export function useBrowserPanelOcclusion(
desired = desiredLayer()
if (!desired || !decoded) continue
+ // Painting a capture whose geometry no longer matches the host is the
+ // flash: a modal's scroll lock changes the window's content width
+ // between capture and paint, so the replacement lands offset from the
+ // page it is standing in for. Skip that frame and re-capture at the
+ // settled layout instead of showing a misaligned one.
+ if (!snapshotMatchesHost(frame, getHostRectRef.current?.() ?? null)) continue
+
const paintId = ++paintIdRef.current
const painted = new Promise((resolve) => {
pendingPaintRef.current = { paintId, resolve }
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts
index 0edd0e9f3f..957dab4d10 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts
@@ -190,6 +190,10 @@ describe('initialUrlSuggestionIndex', () => {
expect(initialUrlSuggestionIndex('https://sim.ai', 3)).toBeNull()
})
+ it('selects the exact search row after typing on an existing page', () => {
+ expect(initialUrlSuggestionIndex('https://sim.ai', 3, 'what is the best')).toBe(0)
+ })
+
it('selects nothing when there are no suggestions', () => {
expect(initialUrlSuggestionIndex('', 0)).toBeNull()
})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx
index 3d241ca738..b4e7d90c0f 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx
@@ -39,6 +39,7 @@ import { onFocusVisibleBrowserOmnibox } from '@/lib/browser-agent/renderer-short
import {
fillBrowserCredential,
loadBrowserFillOptions,
+ loadBrowserSearchSuggestions,
loadBrowserSuggestionSources,
onBrowserAddToChat,
onBrowserAppearanceThemeChanged,
@@ -84,12 +85,16 @@ import {
import { BrowserTabStrip } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-strip'
import { BrowserThemeNotice } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-theme-notice'
import {
+ buildOmniboxSuggestions,
+ googleSearchUrl,
+ isSearchQueryInput,
mergeSuggestionSources,
moveActiveIndex,
- rankSuggestions,
+ type OmniboxSuggestion,
type UrlSuggestion,
} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions'
import { ResourceZoomMenuItems } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/resource-zoom-menu-items'
+import { useDebounce } from '@/hooks/use-debounce'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useBrowserSessionStore } from '@/stores/browser-session/store'
import { MOTHERSHIP_WIDTH } from '@/stores/constants'
@@ -97,6 +102,7 @@ import type { ChatContext } from '@/stores/panel'
/** Ties the omnibox to its listbox for assistive tech. */
const SUGGESTIONS_LIST_ID = 'browser-url-suggestions'
+const SEARCH_SUGGESTIONS_DEBOUNCE_MS = 160
const NEW_TAB_CONFIRM_TIMEOUT_MS = 10_000
const EMPTY_BROWSER_TABS: BrowserTabState[] = []
@@ -156,14 +162,10 @@ export function browserSelectionContext({
export function resolveUrlBarInput(raw: string): string {
const input = raw.trim()
if (/^https?:\/\//i.test(input)) return input
- const hostLike =
- /^([a-z0-9-]+(\.[a-z0-9-]+)+|localhost|\d{1,3}(\.\d{1,3}){3}|\[[0-9a-f:]+\])(:\d+)?([/?#].*)?$/i
- if (!input.includes(' ') && hostLike.test(input)) {
- const isLocal =
- /^(localhost|127\.\d{1,3}\.\d{1,3}\.\d{1,3}|0\.0\.0\.0|\[::1?\])(:\d+)?([/?#]|$)/i.test(input)
- return `${isLocal ? 'http' : 'https'}://${input}`
- }
- return `https://www.google.com/search?q=${encodeURIComponent(input)}`
+ if (isSearchQueryInput(input)) return googleSearchUrl(input)
+ const isLocal =
+ /^(localhost|127\.\d{1,3}\.\d{1,3}\.\d{1,3}|0\.0\.0\.0|\[::1?\])(:\d+)?([/?#]|$)/i.test(input)
+ return `${isLocal ? 'http' : 'https'}://${input}`
}
/**
@@ -302,13 +304,14 @@ export function shouldOpenUrlSuggestions(
return activeOverlay === 'suggestions' && suggestionCount > 0
}
-/** New tabs submit the best suggestion; existing pages submit their current URL. */
+/** Typed searches and new tabs select the first row; an untouched page URL remains literal. */
export function initialUrlSuggestionIndex(
pageUrl: string | undefined,
- suggestionCount: number
+ suggestionCount: number,
+ query = ''
): number | null {
if (suggestionCount === 0) return null
- return !pageUrl || pageUrl === 'about:blank' ? 0 : null
+ return query.trim() || !pageUrl || pageUrl === 'about:blank' ? 0 : null
}
/** A new-tab request is complete only after the authoritative strip grows and activates a new id. */
@@ -369,6 +372,9 @@ export function BrowserSession({
const suspended = useBrowserSessionStore((state) => state.sessions[scopeId]?.suspended ?? false)
const panelRef = useRef(null)
const hostRef = useRef(null)
+ // Lets the occlusion handshake reject a capture taken before a modal's
+ // scroll lock reflowed the panel — painting that stale rect is the flash.
+ const getHostRect = useCallback(() => hostRef.current?.getBoundingClientRect() ?? null, [])
const urlInputRef = useRef(null)
const findInputRef = useRef(null)
const fillButtonRef = useRef(null)
@@ -429,6 +435,15 @@ export function BrowserSession({
const [suggestionsVisible, setSuggestionsVisible] = useState(false)
/** Empty on initial focus; follows the typed text once the user edits it. */
const [suggestionQuery, setSuggestionQuery] = useState(null)
+ /** Live completions tagged with the query that produced them, so late replies cannot leak in. */
+ const [searchCompletions, setSearchCompletions] = useState<{
+ query: string
+ values: string[]
+ }>({ query: '', values: [] })
+ const debouncedSuggestionQuery = useDebounce(
+ suggestionQuery ?? '',
+ SEARCH_SUGGESTIONS_DEBOUNCE_MS
+ )
/** Whether the find bar is docked above the page. */
const [findOpen, setFindOpen] = useState(false)
const {
@@ -438,7 +453,7 @@ export function BrowserSession({
requestOverlay,
closeOverlay,
onSnapshotError,
- } = useBrowserPanelOcclusion(scopeId, activeTabId, panelVisible)
+ } = useBrowserPanelOcclusion(scopeId, activeTabId, panelVisible, getHostRect)
// The resource picker lives above this component in the panel tab bar. Give
// that one external browser overlay access to the same capture/hide handshake
@@ -502,6 +517,23 @@ export function BrowserSession({
}
}, [panelVisible])
+ /** Debounced live completions never block the immediate local/search row. */
+ useEffect(() => {
+ const query = debouncedSuggestionQuery.trim()
+ if (!suggestionsVisible || !isSearchQueryInput(query)) {
+ setSearchCompletions({ query: '', values: [] })
+ return
+ }
+
+ let active = true
+ void loadBrowserSearchSuggestions(query).then((values) => {
+ if (active) setSearchCompletions({ query, values })
+ })
+ return () => {
+ active = false
+ }
+ }, [debouncedSuggestionQuery, suggestionsVisible])
+
useEffect(() => {
if (appearanceTheme) {
const next = resolveDesktopAppearanceTheme(appearanceTheme, theme)
@@ -579,6 +611,24 @@ export function BrowserSession({
useEffect(() => onBrowserOmniboxFocus(focusOmnibox, scopeId), [focusOmnibox, scopeId])
+ // Follow the agent's tab. The panel already marks the automated tab in the
+ // strip; this makes it the VISIBLE one, so watching the agent never means
+ // hunting for which tab it moved to. Keyed on the automation target
+ // CHANGING, not on it merely being set — the user can still browse a
+ // different tab mid-run and is only pulled along when the agent itself
+ // moves to another tab.
+ const followedAutomationTabRef = useRef(null)
+ useEffect(() => {
+ if (!automationActive || !automationTabId) {
+ if (!automationActive) followedAutomationTabRef.current = null
+ return
+ }
+ if (followedAutomationTabRef.current === automationTabId) return
+ followedAutomationTabRef.current = automationTabId
+ if (automationTabId === activeTabId) return
+ sendBrowserPanelAction('switch-tab', { tabId: automationTabId }, scopeId)
+ }, [activeTabId, automationActive, automationTabId, scopeId])
+
// Sim owns keyboard events while its renderer has focus. Claim Cmd+L here
// before the workspace's global "Go to Logs" command can navigate away.
useEffect(() => {
@@ -748,6 +798,17 @@ export function BrowserSession({
// then report bounds: the WebContentsView is attached hidden from its
// very first compositor frame. This also reasserts the lease after a
// minimized/throttled window temporarily loses its bounds.
+ //
+ // The reassert must be REAL, not deduped. Main's bounds lease expires
+ // after 2.5s of missed reports (renderer jank, a skipped commit) and
+ // resets panelOccluded — while this side still remembers `applied:
+ // true`. Without dropping that belief, setDesired(true) is a no-op,
+ // the next bounds commit lays out an unoccluded native view, and the
+ // browser punches above the still-open modal with nothing left to
+ // ever re-hide it. Forgetting `applied` costs one idempotent hide IPC
+ // per heartbeat while a modal is up, and makes any main-side lease
+ // loss self-heal within a second.
+ if (nativeSurfaceOcclusionPresent) geometryOcclusionLease.assumeRevealed()
void geometryOcclusionLease.setDesired(nativeSurfaceOcclusionPresent).then((settled) => {
if (disposed) return
const latestOcclusionPresent = atomicPanelOcclusion && hasNativeSurfaceOcclusion()
@@ -833,17 +894,18 @@ export function BrowserSession({
* Programmatic focus on a new tab keeps the omnibox ready for typing without
* opening this list. A pointer interaction or typed edit opts into suggestions.
*/
- const suggestions = useMemo(
- () =>
- suggestionsVisible && suggestionQuery !== null
- ? rankSuggestions(suggestionCorpus, suggestionQuery)
- : [],
- [suggestionCorpus, suggestionQuery, suggestionsVisible]
- )
+ const suggestions = useMemo((): OmniboxSuggestion[] => {
+ if (!suggestionsVisible || suggestionQuery === null) return []
+ const query = suggestionQuery.trim()
+ const live = searchCompletions.query === query ? searchCompletions.values : []
+ return buildOmniboxSuggestions(suggestionCorpus, suggestionQuery, live)
+ }, [searchCompletions, suggestionCorpus, suggestionQuery, suggestionsVisible])
useEffect(() => {
- setActiveSuggestion(initialUrlSuggestionIndex(suggestionOriginUrl, suggestions.length))
- }, [suggestionOriginUrl, suggestions])
+ setActiveSuggestion(
+ initialUrlSuggestionIndex(suggestionOriginUrl, suggestions.length, suggestionQuery ?? '')
+ )
+ }, [suggestionOriginUrl, suggestionQuery, suggestions])
// The suggestion list is renderer UI that extends over the native page.
// Keep the page's exact captured frame underneath it while it is open so
@@ -1075,6 +1137,7 @@ export function BrowserSession({
placeholder='Search Google or enter a URL'
autoComplete='off'
role='combobox'
+ aria-autocomplete='list'
aria-expanded={suggestionsOpen}
aria-controls={SUGGESTIONS_LIST_ID}
aria-activedescendant={
@@ -1092,6 +1155,7 @@ export function BrowserSession({
onChange={(event) => {
setSuggestionsVisible(true)
setSuggestionQuery(event.target.value)
+ setSearchCompletions({ query: '', values: [] })
setUrlDraft(event.target.value)
// The old highlight pointed at a row that may no longer be
// in the list, let alone in the same position.
@@ -1150,7 +1214,11 @@ export function BrowserSession({
>
{suggestions.map((suggestion, index) => (
navigateTo(suggestion.url)}
>
-
- {suggestion.name ? (
+ {suggestion.kind === 'search' ? (
+ <>
+
+
{suggestion.query}
+ >
+ ) : (
+
+ )}
+ {suggestion.kind === 'site' && suggestion.name ? (
{suggestion.name}
— {suggestion.hostname}
- ) : (
+ ) : suggestion.kind === 'site' ? (
{suggestion.hostname}
- )}
+ ) : null}
))}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.test.ts
index f4407ab3f6..978d67badf 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.test.ts
@@ -2,6 +2,9 @@ import type { BrowserKnownSession, BrowserSessionEvidence } from '@sim/browser-p
import type { BrowserCredentialMetadata, BrowserSiteInfo } from '@sim/desktop-bridge'
import { describe, expect, it } from 'vitest'
import {
+ buildOmniboxSuggestions,
+ googleSearchUrl,
+ isSearchQueryInput,
mergeSuggestionSources,
moveActiveIndex,
rankSuggestions,
@@ -421,6 +424,65 @@ describe('rankSuggestions', () => {
})
})
+describe('buildOmniboxSuggestions', () => {
+ it('leads with the exact search, keeps matching sites, then adds live completions', () => {
+ const results = buildOmniboxSuggestions(
+ [suggestion('mail.google.com', 100, 'Gmail')],
+ 'gmail',
+ ['gmail login', 'gmail account']
+ )
+
+ expect(results.map((result) => [result.kind, result.url])).toEqual([
+ ['search', googleSearchUrl('gmail')],
+ ['site', 'https://mail.google.com'],
+ ['search', googleSearchUrl('gmail login')],
+ ['search', googleSearchUrl('gmail account')],
+ ])
+ })
+
+ it('does not send URL-looking input through search completions', () => {
+ const results = buildOmniboxSuggestions([suggestion('github.com', 100)], 'github.com', [
+ 'github.com login',
+ ])
+
+ expect(results).toHaveLength(1)
+ expect(results[0]).toMatchObject({ kind: 'site', hostname: 'github.com' })
+ })
+
+ it('deduplicates completions and caps the combined dropdown', () => {
+ const results = buildOmniboxSuggestions(
+ [],
+ 'sim ai',
+ ['sim ai', 'SIM AI', 'sim ai workflow', 'sim ai agents'],
+ 2
+ )
+
+ expect(results.map((result) => result.kind === 'search' && result.query)).toEqual([
+ 'sim ai',
+ 'sim ai workflow',
+ ])
+ })
+
+ it('keeps an empty omnibox local-only', () => {
+ const results = buildOmniboxSuggestions([suggestion('github.com', 100)], '', [
+ 'ignored remote completion',
+ ])
+
+ expect(results).toHaveLength(1)
+ expect(results[0]).toMatchObject({ kind: 'site', hostname: 'github.com' })
+ })
+})
+
+describe('isSearchQueryInput', () => {
+ it('distinguishes searches from navigable addresses', () => {
+ expect(isSearchQueryInput('what is the best browser')).toBe(true)
+ expect(isSearchQueryInput('electron')).toBe(true)
+ expect(isSearchQueryInput('sim.ai/docs')).toBe(false)
+ expect(isSearchQueryInput('https://sim.ai')).toBe(false)
+ expect(isSearchQueryInput('localhost:3000')).toBe(false)
+ })
+})
+
describe('moveActiveIndex', () => {
it('highlights nothing until the user arrows in, so Enter still means "go to what I typed"', () => {
expect(moveActiveIndex(null, 1, 3)).toBe(0)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.ts
index c5ae7399c7..4ab226a38f 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.ts
@@ -47,6 +47,25 @@ export interface UrlSuggestion {
visits?: number
}
+export type OmniboxSuggestion =
+ | ({ kind: 'site' } & UrlSuggestion)
+ | { kind: 'search'; query: string; url: string }
+
+const HOST_LIKE_INPUT =
+ /^([a-z0-9-]+(\.[a-z0-9-]+)+|localhost|\d{1,3}(\.\d{1,3}){3}|\[[0-9a-f:]+\])(:\d+)?([/?#].*)?$/i
+
+/** Whether an omnibox value should search rather than navigate directly. */
+export function isSearchQueryInput(raw: string): boolean {
+ const input = raw.trim()
+ if (!input || /^https?:\/\//i.test(input)) return false
+ return input.includes(' ') || !HOST_LIKE_INPUT.test(input)
+}
+
+/** The canonical Google results URL used by search rows and bare submission. */
+export function googleSearchUrl(query: string): string {
+ return `https://www.google.com/search?q=${encodeURIComponent(query.trim())}`
+}
+
function timestamp(value: string | undefined): number {
if (!value) return 0
const parsed = Date.parse(value)
@@ -189,6 +208,42 @@ export function rankSuggestions(
return scored.slice(0, limit).map((entry) => entry.suggestion)
}
+/**
+ * Combines immediate navigation/search actions with the user's known sites and
+ * live completions. The exact typed search leads, known sites retain priority,
+ * and remote completions fill whatever room remains.
+ */
+export function buildOmniboxSuggestions(
+ siteCorpus: readonly UrlSuggestion[],
+ rawQuery: string,
+ searchCompletions: readonly string[] = [],
+ limit: number = MAX_URL_SUGGESTIONS
+): OmniboxSuggestion[] {
+ if (limit <= 0) return []
+ const query = rawQuery.trim()
+ const sites = rankSuggestions(siteCorpus, query, limit)
+ if (!query || !isSearchQueryInput(query)) {
+ return sites.map((site) => ({ ...site, kind: 'site' }))
+ }
+
+ const results: OmniboxSuggestion[] = [{ kind: 'search', query, url: googleSearchUrl(query) }]
+ for (const site of sites) {
+ if (results.length === limit) return results
+ results.push({ ...site, kind: 'site' })
+ }
+
+ const seen = new Set([query.toLocaleLowerCase()])
+ for (const candidate of searchCompletions) {
+ const completion = candidate.trim()
+ const key = completion.toLocaleLowerCase()
+ if (!completion || seen.has(key)) continue
+ seen.add(key)
+ results.push({ kind: 'search', query: completion, url: googleSearchUrl(completion) })
+ if (results.length === limit) break
+ }
+ return results
+}
+
/**
* How well the browser knows a host, then how much it is used, then how
* recently, then alphabetically so the same corpus always comes back in the
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx
index 4378ba9a3c..48f3769b17 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx
@@ -256,6 +256,7 @@ export const ResourceContent = memo(function ResourceContent({
tableId={resource.id}
embedded
viewsEnabled={tableViewsEnabled}
+ initialViewId={resource.viewId}
/>
)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx
index d907369426..b4333837c4 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx
@@ -243,9 +243,9 @@ const ResourceTabItem = memo(function ResourceTabItem({
>
{config.renderTabIcon(resource, 'mr-1.5 size-[14px]')}
{displayName}
- {hasActivity && !isActive && (
+ {hasActivity && !isActive && !isHovered && (
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx
index d82d73db63..621e541fae 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx
@@ -4,6 +4,7 @@ import { forwardRef, memo, useCallback, useMemo, useRef, useState } from 'react'
import { cn } from '@sim/emcn'
import type { FilePreviewSession } from '@/lib/copilot/request/session'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
+import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile'
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import {
isCsvStreamOnly,
@@ -164,7 +165,10 @@ export const MothershipView = memo(
// the record before deciding so the toggle doesn't flash on for a large CSV — but don't gate
// other rich types (html, svg, …) on the file list loading.
!(isActiveCsv && filesLoading) &&
- !(activeFile && isCsvStreamOnly(activeFile))
+ !(activeFile && isCsvStreamOnly(activeFile)) &&
+ // A Sim page is locked to its rendered view (the pdf model — the raw
+ // source is not a mode this surface offers), so no toggle either.
+ activeFile?.type !== SIM_PAGE_CONTENT_TYPE
return (
new Set(current).add(resourceId))
return
}
@@ -228,7 +234,10 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
next.delete(resourceId)
return next
})
- if (activeResourceId !== resourceId) setActiveResourceUrl(resourceId)
+ if (activeResourceId !== resourceId) {
+ activeResourceParamRef.current = resourceId
+ setActiveResourceUrl(resourceId)
+ }
}
const {
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts
index 995df51986..8c1fa13edd 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts
@@ -1,6 +1,8 @@
+export type { ResourceEventOptions } from './use-chat'
export {
getMothershipUseChatOptions,
getWorkflowCopilotUseChatOptions,
+ shouldActivateResourceEvent,
useChat,
} from './use-chat'
export { useMothershipResize } from './use-mothership-resize'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts
index 296f48bc55..3635ac65d5 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts
@@ -4,7 +4,7 @@ import {
MothershipStreamV1ToolPhase,
MothershipStreamV1ToolStatus,
} from '@/lib/copilot/generated/mothership-stream-v1'
-import { WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1'
+import { ApplyFileEdit, PrepareFileEdit } from '@/lib/copilot/generated/tool-catalog-v1'
import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract'
import {
extractResourcesFromToolResult,
@@ -79,7 +79,7 @@ function runToolResultSideEffects(ctx: StreamLoopContext, node: ToolNode): void
invalidateResourceQueries(deps.queryClient, deps.workspaceId, resource.type, resource.id)
}
- if ((name === 'edit_content' || name === WorkspaceFile.id) && isSuccess) {
+ if ((name === ApplyFileEdit.id || name === PrepareFileEdit.id) && isSuccess) {
const out = output as Record
| undefined
const editData =
out && typeof out.data === 'object' && out.data !== null
@@ -102,17 +102,20 @@ function runToolResultSideEffects(ctx: StreamLoopContext, node: ToolNode): void
deps.onToolResultRef.current?.(name, isSuccess, output)
const workspaceFileOperation =
- name === WorkspaceFile.id && typeof params?.operation === 'string'
+ name === PrepareFileEdit.id && typeof params?.operation === 'string'
? params.operation
: undefined
const shouldKeepWorkspacePreviewOpen =
- name === WorkspaceFile.id &&
+ name === PrepareFileEdit.id &&
(workspaceFileOperation === 'append' ||
workspaceFileOperation === 'update' ||
workspaceFileOperation === 'patch')
- if ((name === WorkspaceFile.id || name === 'edit_content') && !shouldKeepWorkspacePreviewOpen) {
- if (name === WorkspaceFile.id) {
+ if (
+ (name === PrepareFileEdit.id || name === ApplyFileEdit.id) &&
+ !shouldKeepWorkspacePreviewOpen
+ ) {
+ if (name === PrepareFileEdit.id) {
deps.removePreviewSessionImmediate(node.id)
}
const fileResource = extractedResources.find((r) => r.type === 'file')
@@ -128,7 +131,7 @@ function runToolResultSideEffects(ctx: StreamLoopContext, node: ToolNode): void
/**
* Side effects for tool events. State (the tool node, its status, args, and the
- * edit_content row merge) is owned by `reduceEvent`; this handler routes preview
+ * apply_file_edit row merge) is owned by `reduceEvent`; this handler routes preview
* phases, fires client workflow tools, and runs result side effects, then
* flushes the model-derived snapshot.
*/
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts
index 90e8d0f9ca..afeb703e08 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts
@@ -2,35 +2,37 @@ import { createLogger } from '@sim/logger'
import { isRecordLike } from '@sim/utils/object'
import {
CallIntegrationTool,
- CrawlWebsite,
- CreateFile,
+ CreateEmptyFile,
CreateWorkflow,
- DeployApi,
- DeployChat,
- DeployMcp,
+ DeployAsApi,
+ DeployAsChat,
+ DeployAsMcp,
EditWorkflow,
- FunctionExecute,
Glob,
Grep,
ManageCredential,
ManageCustomTool,
- ManageMcpTool,
+ ManageMcpConnection,
ManageSkill,
+ PrepareFileEdit,
+ PrepareFileEditOperation,
QueryLogs,
Redeploy,
Rm,
RunFromBlock,
+ RunFunction,
RunWorkflow,
RunWorkflowUntilBlock,
- ScrapePage,
- SearchOnline,
- WorkspaceFile,
- WorkspaceFileOperation,
+ WebCrawl,
+ WebScrape,
+ WebSearch,
} from '@/lib/copilot/generated/tool-catalog-v1'
import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args'
import { getToolDisplayTitle, mvDisplayVerb } from '@/lib/copilot/tools/tool-display'
+import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types'
import { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'
+import { tableKeys } from '@/hooks/queries/utils/table-keys'
import { getWorkflowById } from '@/hooks/queries/utils/workflow-cache'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -40,9 +42,9 @@ const logger = createLogger('StreamHelpers')
export const FILE_SUBAGENT_ID = 'file'
export const DEPLOY_TOOL_NAMES: Set = new Set([
- DeployApi.id,
- DeployChat.id,
- DeployMcp.id,
+ DeployAsApi.id,
+ DeployAsChat.id,
+ DeployAsMcp.id,
Redeploy.id,
])
@@ -123,6 +125,33 @@ function resolveTargetWorkflowName(args: Record | undefined): s
return resolveWorkflowNameForDisplay(args?.workflowId ?? registry.hydration.workflowId)
}
+/**
+ * Table name for a nested `args.tableId`. Tables reach the client through
+ * React Query rather than a Zustand store, so the cached workspace list is
+ * the synchronous source a title can read; an uncached id simply stays
+ * unnamed rather than blocking the row.
+ */
+function resolveTableNameForDisplay(tableId: unknown): string | undefined {
+ const id = stringParam(tableId)
+ if (!id) return undefined
+ const cache = getQueryClient().getQueryCache()
+ for (const query of cache.findAll({ queryKey: tableKeys.lists() })) {
+ const data = query.state.data
+ const tables = Array.isArray(data)
+ ? data
+ : isRecordLike(data) && Array.isArray((data as { tables?: unknown }).tables)
+ ? ((data as { tables: unknown[] }).tables as unknown[])
+ : []
+ for (const table of tables) {
+ if (!isRecordLike(table)) continue
+ if (stringParam(table.id) !== id) continue
+ const name = stringParam(table.name)
+ if (name) return name
+ }
+ }
+ return undefined
+}
+
function resolveBlockNameForDisplay(blockId: unknown): string | undefined {
const id = stringParam(blockId)
if (!id) return undefined
@@ -139,13 +168,13 @@ function resolveWorkspaceFileDisplayTitle(
let verb = 'Writing'
switch (operation) {
- case WorkspaceFileOperation.append:
+ case PrepareFileEditOperation.append:
verb = 'Adding'
break
- case WorkspaceFileOperation.patch:
+ case PrepareFileEditOperation.patch:
verb = 'Editing'
break
- case WorkspaceFileOperation.update:
+ case PrepareFileEditOperation.update:
verb = 'Writing'
break
}
@@ -186,6 +215,40 @@ export function resolveIntegrationToolDisplayTitle(tool: {
return tool.integrationDescription
}
+/**
+ * Tools whose subject is one workflow. They accept a `workflowId` (or imply
+ * the current workflow), so their titles can only name the workflow once the
+ * client resolves the id against the workflow registry.
+ */
+const TABLE_SCOPED_TOOL_IDS = new Set([
+ 'table_automations',
+ 'table_columns',
+ 'table_enrichments',
+ 'table_manage',
+ 'table_rows',
+ 'table_views',
+])
+
+const WORKFLOW_SCOPED_TOOL_IDS = new Set([
+ 'deploy_as_api',
+ 'diff_workflows',
+ 'list_deployment_versions',
+ 'publish_custom_block',
+ 'deploy_as_chat',
+ 'deploy_as_mcp',
+ 'get_block_outputs',
+ 'get_block_upstream_references',
+ 'get_deployed_workflow_state',
+ 'get_deployment_status',
+ 'get_workflow_data',
+ 'get_workflow_run_options',
+ 'promote_to_live',
+ 'redeploy',
+ 'run_block',
+ 'set_block_enabled',
+ 'set_global_workflow_variables',
+])
+
export function resolveToolDisplayTitle(name: string, args?: Record): string {
// Cases that enrich the title with live workspace/block names from the client
// stores. Everything else is resolved by the shared name+args resolver, which
@@ -224,6 +287,39 @@ export function resolveToolDisplayTitle(name: string, args?: Record) : undefined
+ const tableName =
+ stringParam(args?.tableName) ?? resolveTableNameForDisplay(nested?.tableId ?? args?.tableId)
+ if (nested || tableName) {
+ return getToolDisplayTitle(name, {
+ ...args,
+ ...(nested ?? {}),
+ ...(tableName ? { tableName } : {}),
+ })
+ }
+ }
+
+ if (WORKFLOW_SCOPED_TOOL_IDS.has(name) && !stringParam(args?.workflowName)) {
+ const workflowName = resolveTargetWorkflowName(args)
+ // Block-scoped tools carry a blockId for the same reason; resolve it too,
+ // so a row says which block ran rather than an opaque id (or nothing).
+ const blockName = stringParam(args?.blockName) ?? resolveBlockNameForDisplay(args?.blockId)
+ const enriched = {
+ ...args,
+ ...(workflowName ? { workflowName } : {}),
+ ...(blockName ? { blockName } : {}),
+ }
+ if (workflowName || blockName) return getToolDisplayTitle(name, enriched)
+ }
+
return getToolDisplayTitle(name, args)
}
@@ -270,11 +366,11 @@ export function resolveStreamingToolDisplayTitle(
name: string,
streamingArgs: string
): string | undefined {
- if (name === FunctionExecute.id) {
+ if (name === RunFunction.id) {
return functionExecuteTitle(matchStreamingStringArg(streamingArgs, 'title'))
}
- if (name === WorkspaceFile.id) {
+ if (name === PrepareFileEdit.id) {
return resolveWorkspaceFileDisplayTitle(
matchStreamingStringArg(streamingArgs, 'operation'),
matchStreamingStringArg(streamingArgs, 'title'),
@@ -282,7 +378,7 @@ export function resolveStreamingToolDisplayTitle(
)
}
- if (name === CreateFile.id) {
+ if (name === CreateEmptyFile.id) {
const target =
matchStreamingStringArg(streamingArgs, 'path') ??
matchStreamingStringArg(streamingArgs, 'fileName')
@@ -299,7 +395,7 @@ export function resolveStreamingToolDisplayTitle(
return workflowId ? resolveToolDisplayTitle(name, { workflowId }) : undefined
}
- if (name === SearchOnline.id) {
+ if (name === WebSearch.id) {
const toolTitle = matchStreamingStringArg(streamingArgs, 'toolTitle')
return toolTitle ? `Searching online for ${toolTitle}` : undefined
}
@@ -349,12 +445,12 @@ export function resolveStreamingToolDisplayTitle(
return toolTitle ? `Deleting ${toolTitle}` : undefined
}
- if (name === ScrapePage.id) {
+ if (name === WebScrape.id) {
const url = matchStreamingStringArg(streamingArgs, 'url')
return url ? `Scraping ${url}` : undefined
}
- if (name === CrawlWebsite.id) {
+ if (name === WebCrawl.id) {
const url = matchStreamingStringArg(streamingArgs, 'url')
return url ? `Crawling ${url}` : undefined
}
@@ -363,7 +459,7 @@ export function resolveStreamingToolDisplayTitle(
return resolveStreamingManagedResourceTitle(name, streamingArgs, ['toolTitle', 'title', 'name'])
}
- if (name === ManageMcpTool.id) {
+ if (name === ManageMcpConnection.id) {
return resolveStreamingManagedResourceTitle(name, streamingArgs, [
'serverName',
'name',
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.test.ts
index d4a5b69e3a..a764b32fa9 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.test.ts
@@ -68,7 +68,7 @@ describe('streaming resource titles', () => {
})
// A main-agent file delegation: trigger tool (main lane), subagent span, inner
-// workspace_file, span end, delegation result.
+// prepare_file_edit, span end, delegation result.
function fileDelegationEvents(): PersistedStreamEventEnvelope[] {
const sub: Scope = {
lane: 'subagent',
@@ -89,13 +89,13 @@ function fileDelegationEvents(): PersistedStreamEventEnvelope[] {
env(
4,
'tool',
- { phase: 'call', toolCallId: 'wf-1', toolName: 'workspace_file' },
+ { phase: 'call', toolCallId: 'wf-1', toolName: 'prepare_file_edit' },
{ lane: 'subagent', spanId: 'S1' }
),
env(
5,
'tool',
- { phase: 'result', toolCallId: 'wf-1', toolName: 'workspace_file', success: true },
+ { phase: 'result', toolCallId: 'wf-1', toolName: 'prepare_file_edit', success: true },
{ lane: 'subagent', spanId: 'S1' }
),
env(
@@ -124,7 +124,7 @@ describe('modelToContentBlocks', () => {
expect(trigger?.toolCall?.status).toBe('success')
const innerTool = blocksByType(blocks, 'tool_call').find(
- (b) => b.toolCall?.name === 'workspace_file'
+ (b) => b.toolCall?.name === 'prepare_file_edit'
)
expect(innerTool?.spanId).toBe('S1')
expect(innerTool?.toolCall?.calledBy).toBe('file')
@@ -220,7 +220,7 @@ describe('modelToContentBlocks', () => {
env(
4,
'tool',
- { phase: 'call', toolCallId: 'wf-1', toolName: 'workspace_file' },
+ { phase: 'call', toolCallId: 'wf-1', toolName: 'prepare_file_edit' },
{ lane: 'subagent', spanId: 'S1' }
),
env(
@@ -233,7 +233,7 @@ describe('modelToContentBlocks', () => {
])
)
const types = blocks.map((b) => b.type)
- const innerIdx = blocks.findIndex((b) => b.toolCall?.name === 'workspace_file')
+ const innerIdx = blocks.findIndex((b) => b.toolCall?.name === 'prepare_file_edit')
const endIdx = types.indexOf('subagent_end')
const afterIdx = blocks.findIndex((b) => b.type === 'text' && b.content === 'after')
// subagent_end sits after the inner work and before the trailing main text — no sibling jumps.
@@ -275,7 +275,7 @@ describe('modelToContentBlocks', () => {
env(
3,
'tool',
- { phase: 'call', toolCallId: 'wf-1', toolName: 'workspace_file' },
+ { phase: 'call', toolCallId: 'wf-1', toolName: 'prepare_file_edit' },
{ lane: 'subagent', spanId: 'S1' }
),
])
@@ -309,7 +309,7 @@ describe('modelToContentBlocks', () => {
env(1, 'tool', {
phase: 'call',
toolCallId: 'wf',
- toolName: 'workspace_file',
+ toolName: 'prepare_file_edit',
arguments: { operation: 'create', title: 'My Doc' },
}),
])
@@ -322,7 +322,7 @@ describe('modelToContentBlocks', () => {
const blocks = modelToContentBlocks(build(fileDelegationEvents()))
const startIdx = blocks.findIndex((b) => b.type === 'subagent')
const innerIdx = blocks.findIndex(
- (b) => b.type === 'tool_call' && b.toolCall?.name === 'workspace_file'
+ (b) => b.type === 'tool_call' && b.toolCall?.name === 'prepare_file_edit'
)
const endIdx = blocks.findIndex((b) => b.type === 'subagent_end')
expect(startIdx).toBeGreaterThanOrEqual(0)
@@ -425,7 +425,7 @@ describe('contentBlocksToModel round-trip', () => {
env(
3,
'tool',
- { phase: 'call', toolCallId: 'wf-1', toolName: 'workspace_file' },
+ { phase: 'call', toolCallId: 'wf-1', toolName: 'prepare_file_edit' },
{ lane: 'subagent', spanId: 'S1' }
),
])
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts
index d82bb65783..6088ee0419 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts
@@ -167,6 +167,7 @@ export function modelToContentBlocks(model: TurnModel): ContentBlock[] {
block: {
type: 'subagent',
content: node.agentId,
+ ...(node.displayName ? { subagentName: node.displayName } : {}),
spanId: node.spanId,
parentSpanId: node.parentSpanId,
...(node.triggerToolCallId ? { parentToolCallId: node.triggerToolCallId } : {}),
@@ -267,7 +268,10 @@ export function contentBlocksToModel(blocks: ContentBlock[]): TurnModel {
kind: 'subagent',
event: 'start',
agent: block.content,
- data: block.parentToolCallId ? { tool_call_id: block.parentToolCallId } : {},
+ data: {
+ ...(block.parentToolCallId ? { tool_call_id: block.parentToolCallId } : {}),
+ ...(block.subagentName ? { name: block.subagentName } : {}),
+ },
},
scopeFor(block),
block.timestamp
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts
index 4681ae8e40..85c0b420e7 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts
@@ -144,17 +144,17 @@ describe('reduceEvent — tool lifecycle', () => {
it('accumulates streaming args across deltas', () => {
const m = apply([
- toolCall(1, 'tc-1', 'workspace_file'),
+ toolCall(1, 'tc-1', 'prepare_file_edit'),
envelope(2, 'tool', {
phase: 'args_delta',
toolCallId: 'tc-1',
- toolName: 'workspace_file',
+ toolName: 'prepare_file_edit',
argumentsDelta: '{"a":',
}),
envelope(3, 'tool', {
phase: 'args_delta',
toolCallId: 'tc-1',
- toolName: 'workspace_file',
+ toolName: 'prepare_file_edit',
argumentsDelta: '1}',
}),
])
@@ -164,11 +164,11 @@ describe('reduceEvent — tool lifecycle', () => {
it('clears streamingArgs once the result settles the tool', () => {
const m = apply([
- toolCall(1, 'tc-1', 'workspace_file'),
+ toolCall(1, 'tc-1', 'prepare_file_edit'),
envelope(2, 'tool', {
phase: 'args_delta',
toolCallId: 'tc-1',
- toolName: 'workspace_file',
+ toolName: 'prepare_file_edit',
argumentsDelta: '{"operation":"create"',
}),
toolResult(3, 'tc-1', true),
@@ -213,11 +213,11 @@ describe('reduceEvent — tool lifecycle', () => {
it('ignores preview phases (decoupled from tool status)', () => {
const m = apply([
- toolCall(1, 'tc-1', 'workspace_file'),
+ toolCall(1, 'tc-1', 'prepare_file_edit'),
envelope(2, 'tool', {
previewPhase: 'file_preview_content',
toolCallId: 'tc-1',
- toolName: 'workspace_file',
+ toolName: 'prepare_file_edit',
content: 'x',
contentMode: 'delta',
fileName: 'f',
@@ -237,6 +237,29 @@ describe('reduceEvent — subagent lifecycle', () => {
expect(agent(m, 'S1').parentSpanId).toBe(MAIN_SPAN)
})
+ it('captures the orchestrator-chosen display name from span start data', () => {
+ const m = apply([
+ envelope(
+ 1,
+ 'span',
+ {
+ kind: 'subagent',
+ event: 'start',
+ agent: 'research',
+ data: { tool_call_id: 'tc-r', name: 'Pricing research' },
+ },
+ {
+ lane: 'subagent',
+ spanId: 'S1',
+ parentSpanId: MAIN_SPAN,
+ parentToolCallId: 'tc-r',
+ agentId: 'research',
+ }
+ ),
+ ])
+ expect(agent(m, 'S1').displayName).toBe('Pricing research')
+ })
+
it('settles an agent error when span end carries an error', () => {
const m = apply([
spanStart(1, 'S1', 'file', 'tc-file'),
@@ -270,8 +293,8 @@ describe('reduceEvent — subagent lifecycle', () => {
const m = apply([
spanStart(1, 'S1', 'file', 'tc-a'),
spanStart(2, 'S2', 'file', 'tc-b'),
- toolCall(3, 'wf-a', 'workspace_file', { lane: 'subagent', spanId: 'S1' }),
- toolCall(4, 'wf-b', 'workspace_file', { lane: 'subagent', spanId: 'S2' }),
+ toolCall(3, 'wf-a', 'prepare_file_edit', { lane: 'subagent', spanId: 'S1' }),
+ toolCall(4, 'wf-b', 'prepare_file_edit', { lane: 'subagent', spanId: 'S2' }),
toolResult(5, 'wf-a', true),
spanEnd(6, 'S1', 'file'),
toolResult(7, 'wf-b', true),
@@ -327,7 +350,7 @@ describe('reduceEvent — idempotency', () => {
it('rebuilds the identical model when replayed into a fresh model', () => {
const events = [
spanStart(1, 'S1', 'file', 'tc-file'),
- toolCall(2, 'wf', 'workspace_file', { lane: 'subagent', spanId: 'S1' }),
+ toolCall(2, 'wf', 'prepare_file_edit', { lane: 'subagent', spanId: 'S1' }),
toolResult(3, 'wf', true),
spanEnd(4, 'S1', 'file'),
complete(5),
@@ -340,42 +363,42 @@ describe('reduceEvent — idempotency', () => {
})
})
-describe('reduceEvent — edit_content row merge', () => {
- it('folds an edit_content write into its span workspace_file row', () => {
+describe('reduceEvent — apply_file_edit row merge', () => {
+ it('folds an apply_file_edit write into its span prepare_file_edit row', () => {
const sub: Scope = { lane: 'subagent', spanId: 'S1' }
const m = apply([
spanStart(1, 'S1', 'file', 'tc-file'),
- toolCall(2, 'wf-1', 'workspace_file', sub),
+ toolCall(2, 'wf-1', 'prepare_file_edit', sub),
toolResult(3, 'wf-1', true, undefined, sub),
- toolCall(4, 'ec-1', 'edit_content', sub),
+ toolCall(4, 'ec-1', 'apply_file_edit', sub),
])
- // No separate edit_content node; the workspace_file row reopened for the edit.
+ // No separate apply_file_edit node; the prepare_file_edit row reopened for the edit.
expect(m.nodes.has('ec-1')).toBe(false)
expect(tool(m, 'wf-1').status).toBe('running')
expect(m.toolAlias.get('ec-1')).toBe('wf-1')
})
- it('settles the merged row on the edit_content result', () => {
+ it('settles the merged row on the apply_file_edit result', () => {
const sub: Scope = { lane: 'subagent', spanId: 'S1' }
const m = apply([
spanStart(1, 'S1', 'file', 'tc-file'),
- toolCall(2, 'wf-1', 'workspace_file', sub),
- toolCall(3, 'ec-1', 'edit_content', sub),
+ toolCall(2, 'wf-1', 'prepare_file_edit', sub),
+ toolCall(3, 'ec-1', 'apply_file_edit', sub),
toolResult(4, 'ec-1', true, undefined, sub),
])
expect(tool(m, 'wf-1').status).toBe('success')
expect(m.nodes.has('ec-1')).toBe(false)
})
- it('folds an edit_content result that raced ahead of its call into the merged row', () => {
+ it('folds an apply_file_edit result that raced ahead of its call into the merged row', () => {
const sub: Scope = { lane: 'subagent', spanId: 'S1' }
const m = apply([
spanStart(1, 'S1', 'file', 'tc-file'),
- toolCall(2, 'wf-1', 'workspace_file', sub),
- // Result for edit_content arrives BEFORE its call (buffered under ec-1)...
+ toolCall(2, 'wf-1', 'prepare_file_edit', sub),
+ // Result for apply_file_edit arrives BEFORE its call (buffered under ec-1)...
toolResult(3, 'ec-1', true, undefined, sub),
// ...then the call lands and aliases ec-1 -> wf-1, draining the buffer.
- toolCall(4, 'ec-1', 'edit_content', sub),
+ toolCall(4, 'ec-1', 'apply_file_edit', sub),
])
expect(tool(m, 'wf-1').status).toBe('success')
expect(tool(m, 'wf-1').result?.success).toBe(true)
@@ -386,13 +409,13 @@ describe('reduceEvent — edit_content row merge', () => {
const sub: Scope = { lane: 'subagent', spanId: 'S1' }
const m = apply([
spanStart(1, 'S1', 'file', 'tc-file'),
- // Section 1: the workspace_file row is reopened by its edit_content, but the
+ // Section 1: the prepare_file_edit row is reopened by its apply_file_edit, but the
// edit's closing result is reordered/dropped — wf-1 is left running.
- toolCall(2, 'wf-1', 'workspace_file', sub),
+ toolCall(2, 'wf-1', 'prepare_file_edit', sub),
toolResult(3, 'wf-1', true, undefined, sub),
- toolCall(4, 'ec-1', 'edit_content', sub),
+ toolCall(4, 'ec-1', 'apply_file_edit', sub),
// Section 2 opens before section 1's edit result lands.
- toolCall(5, 'wf-2', 'workspace_file', sub),
+ toolCall(5, 'wf-2', 'prepare_file_edit', sub),
])
// The previous section settles instead of spinning until the turn terminal...
expect(tool(m, 'wf-1').status).toBe('success')
@@ -498,7 +521,7 @@ describe('turn-terminal propagation', () => {
// A file subagent opened but no span end arrived (mid-stream error/disconnect).
const m = apply([
spanStart(1, 'S1', 'file', 'tc-file'),
- toolCall(2, 'wf-1', 'workspace_file', { lane: 'subagent', spanId: 'S1' }),
+ toolCall(2, 'wf-1', 'prepare_file_edit', { lane: 'subagent', spanId: 'S1' }),
])
expect(agent(m, 'S1').endSeq).toBeUndefined()
applyTurnTerminal(m, 'error')
@@ -556,3 +579,93 @@ describe('reduceEvent — span-start owner reconciliation', () => {
expect((lane as AgentNode).agentId).toBe('workflow')
})
})
+
+describe('reduceEvent — span end settles stale lane tools', () => {
+ const laneScope = { lane: 'subagent', spanId: 'S1', parentToolCallId: 'd1' } as Scope
+
+ it('marks still-running tools success when their lane ends cleanly', () => {
+ const model = apply([
+ envelope(
+ 1,
+ 'span',
+ { kind: 'subagent', event: 'start', agent: 'browser', data: { tool_call_id: 'd1' } },
+ laneScope
+ ),
+ toolCall(2, 'click-1', 'browser_click', laneScope),
+ // No result for click-1 — dropped/reordered past the lane end.
+ envelope(
+ 3,
+ 'span',
+ { kind: 'subagent', event: 'end', agent: 'browser', data: {} },
+ laneScope
+ ),
+ ])
+
+ const click = model.nodes.get('click-1')
+ if (click?.kind !== 'tool') throw new Error('expected tool node')
+ expect(click.status).toBe('success')
+
+ const laneId = model.agentBySpanId.get('S1')
+ const lane = laneId ? model.nodes.get(laneId) : undefined
+ if (lane?.kind !== 'agent') throw new Error('expected agent lane')
+ expect(lane.status).toBe('success')
+ })
+
+ it('marks still-running tools error when the lane ends with an error', () => {
+ const model = apply([
+ envelope(
+ 1,
+ 'span',
+ { kind: 'subagent', event: 'start', agent: 'browser', data: { tool_call_id: 'd1' } },
+ laneScope
+ ),
+ toolCall(2, 'click-1', 'browser_click', laneScope),
+ envelope(
+ 3,
+ 'span',
+ { kind: 'subagent', event: 'end', agent: 'browser', data: { error: 'boom' } },
+ laneScope
+ ),
+ ])
+
+ const click = model.nodes.get('click-1')
+ if (click?.kind !== 'tool') throw new Error('expected tool node')
+ expect(click.status).toBe('error')
+ })
+
+ it('leaves settled tools alone and lets a late result overwrite the settle', () => {
+ const model = apply([
+ envelope(
+ 1,
+ 'span',
+ { kind: 'subagent', event: 'start', agent: 'browser', data: { tool_call_id: 'd1' } },
+ laneScope
+ ),
+ toolCall(2, 'click-1', 'browser_click', laneScope),
+ envelope(
+ 3,
+ 'span',
+ { kind: 'subagent', event: 'end', agent: 'browser', data: {} },
+ laneScope
+ ),
+ // Late result arrives after the settle — it must win.
+ envelope(
+ 4,
+ 'tool',
+ {
+ phase: 'result',
+ toolCallId: 'click-1',
+ toolName: 'browser_click',
+ success: false,
+ error: 'nope',
+ },
+ laneScope
+ ),
+ ])
+
+ const click = model.nodes.get('click-1')
+ if (click?.kind !== 'tool') throw new Error('expected tool node')
+ expect(click.status).toBe('error')
+ expect(click.result?.error).toBe('nope')
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts
index 9139fe504e..6097a22cf7 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts
@@ -85,6 +85,8 @@ export interface AgentNode extends NodeBase {
agentId: string
/** The outer delegation tool_use that triggered this run; links the trigger tool node. */
triggerToolCallId?: string
+ /** Orchestrator-chosen display name for this delegation (falls back to the agent label). */
+ displayName?: string
status: NodeStatus
/** Wire seq at which the run terminated (span end), for ordering the close marker. */
endSeq?: number
@@ -121,7 +123,7 @@ export interface TurnModel {
>
/**
* Maps a tool call id to another tool node it folds into. Used for the
- * `edit_content` -> `workspace_file` row merge so the write streams into the
+ * `apply_file_edit` -> `prepare_file_edit` row merge so the write streams into the
* single "writing" row rather than a second row.
*/
toolAlias: Map
@@ -142,16 +144,16 @@ export function createTurnModel(): TurnModel {
}
}
-const WORKSPACE_FILE_TOOL = 'workspace_file'
-const EDIT_CONTENT_TOOL = 'edit_content'
+const WORKSPACE_FILE_TOOL = 'prepare_file_edit'
+const EDIT_CONTENT_TOOL = 'apply_file_edit'
-/** Resolves a tool call id through the alias map (e.g. edit_content -> its workspace_file row). */
+/** Resolves a tool call id through the alias map (e.g. apply_file_edit -> its prepare_file_edit row). */
export function resolveToolId(model: TurnModel, id: string): string {
return model.toolAlias.get(id) ?? id
}
/**
- * Finds the most recent `workspace_file` tool node in a span so an `edit_content`
+ * Finds the most recent `prepare_file_edit` tool node in a span so an `apply_file_edit`
* write folds into it (the single "writing" row). Co-location in the file
* subagent's span is the link — no coupling to preview phases. The caller
* reopens whatever this returns, including an already-settled row (an edit after
@@ -169,11 +171,11 @@ function findWorkspaceFileNodeInSpan(model: TurnModel, spanId: string): ToolNode
}
/**
- * The file agent writes a file as strictly sequential `workspace_file` +
- * `edit_content` section pairs, waiting for each to finish before the next. So
- * when a new section's `workspace_file` opens, any earlier `workspace_file` row
+ * The file agent writes a file as strictly sequential `prepare_file_edit` +
+ * `apply_file_edit` section pairs, waiting for each to finish before the next. So
+ * when a new section's `prepare_file_edit` opens, any earlier `prepare_file_edit` row
* still `running` in the same span is a completed section whose closing
- * `edit_content` result was reordered or dropped — finalize it as success so its
+ * `apply_file_edit` result was reordered or dropped — finalize it as success so its
* "writing" spinner resolves when the next section starts, instead of lingering
* until the turn-terminal sweep. A no-op on the happy path (prior rows already
* settled on their own result).
@@ -331,8 +333,8 @@ function appendText(
/**
* Applies a result that raced ahead of its tool `call` (buffered under `fromId`)
* onto `node`, then clears the buffer. Used by the normal call path and by the
- * edit_content -> workspace_file merge, where the buffer is keyed by the
- * edit_content id but folds into the workspace_file row.
+ * apply_file_edit -> prepare_file_edit merge, where the buffer is keyed by the
+ * apply_file_edit id but folds into the prepare_file_edit row.
*/
function drainBufferedResult(model: TurnModel, fromId: string, node: ToolNode): void {
const buffered = model.bufferedResults.get(fromId)
@@ -473,12 +475,12 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve
ensureSubagentLane(model, spanId, scope, seq, tsMs)
const phase = payload.phase
if (phase === MothershipStreamV1ToolPhase.call) {
- // edit_content folds into its span's workspace_file row (the write
+ // apply_file_edit folds into its span's prepare_file_edit row (the write
// continues in the single "writing" row), reopening it for the edit.
if (toolName === EDIT_CONTENT_TOOL) {
- // A re-emitted edit_content call (same tool call id — duplicate/replay)
+ // A re-emitted apply_file_edit call (same tool call id — duplicate/replay)
// must keep its ORIGINAL target row. Re-running the span lookup can
- // return a newer workspace_file, and folding into that would leave the
+ // return a newer prepare_file_edit, and folding into that would leave the
// first (already reopened) row running with no result ever closing it —
// a spinner stuck until the turn-terminal sweep. So once aliased, reuse.
const aliasedId = model.toolAlias.get(rawToolCallId)
@@ -492,7 +494,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve
parent.status = 'running'
parent.result = undefined
// A result that raced ahead of this call was buffered under the
- // edit_content id; fold it into the reopened workspace_file row.
+ // apply_file_edit id; fold it into the reopened prepare_file_edit row.
drainBufferedResult(model, rawToolCallId, parent)
break
}
@@ -563,6 +565,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve
const triggerToolCallId =
scope?.parentToolCallId ?? asString(data?.tool_call_id) ?? asString(data?.toolCallId)
const agentId = asString(payload.agent) ?? scope?.agentId ?? ''
+ const displayName = asString(data?.name)
const resolvedSpanId =
scope?.spanId ?? (triggerToolCallId ? `span:${triggerToolCallId}` : `span:${seq}`)
const parentSpanId = scope?.parentSpanId ?? MAIN_SPAN
@@ -581,6 +584,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve
// scope.agentId can name the forwarding caller (e.g. superagent),
// while this start's payload.agent is the authoritative lane owner.
if (agentId && existing.agentId !== agentId) existing.agentId = agentId
+ if (displayName) existing.displayName = displayName
if (!existing.triggerToolCallId && triggerToolCallId) {
existing.triggerToolCallId = triggerToolCallId
}
@@ -602,6 +606,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve
seq: seq,
...(tsMs !== undefined ? { startedAtMs: tsMs } : {}),
...(triggerToolCallId ? { triggerToolCallId } : {}),
+ ...(displayName ? { displayName } : {}),
}
model.nodes.set(node.id, node)
model.order.push(node.id)
@@ -611,10 +616,25 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve
if (data?.pending === true) break
breakLane(model, resolvedSpanId, tsMs)
const node = model.nodes.get(resolvedSpanId)
+ const spanErrored = Boolean(data && asString(data.error))
if (node && node.kind === 'agent' && !isNodeTerminal(node.status)) {
- node.status = data && asString(data.error) ? 'error' : 'success'
+ node.status = spanErrored ? 'error' : 'success'
node.endSeq = seq
}
+ // The lane is over: settle any tool row still `running` in it (its
+ // result was dropped or reordered past the end). Left open, the row
+ // pins the whole group expanded and shimmering for the rest of the
+ // turn even though the subagent already returned. A late result event
+ // still corrects this — applyToolResult overwrites unconditionally.
+ for (const id of model.order) {
+ const stale = model.nodes.get(id)
+ if (stale?.kind === 'tool' && stale.spanId === resolvedSpanId) {
+ if (stale.status === 'running') {
+ stale.status = spanErrored ? 'error' : 'success'
+ stale.streamingArgs = undefined
+ }
+ }
+ }
}
break
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts
index 281714e81d..d0212d24b3 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts
@@ -12,7 +12,10 @@ import {
getReplayCompletedWorkflowToolCallIds,
panelForExecutingClientTool,
reconcileLiveAssistantTurn,
+ selectDeletedWorkflowResources,
selectReconnectReplayState,
+ shouldActivateResourceEvent,
+ shouldQueueOutgoingMessage,
waitForDetachedChatResolution,
} from '@/app/workspace/[workspaceId]/home/hooks/use-chat'
import type {
@@ -30,6 +33,76 @@ vi.mock('next/navigation', () => ({
}),
}))
+describe('selectDeletedWorkflowResources', () => {
+ const resource = (id: string) => ({ type: 'workflow' as const, id, title: id })
+ const cached = (id: string) => ({
+ id,
+ name: id,
+ lastModified: new Date(0),
+ createdAt: new Date(0),
+ sortOrder: 0,
+ })
+
+ it('selects a hydrated workflow the server no longer has', () => {
+ expect(selectDeletedWorkflowResources([resource('wf-gone')], new Set(), [])).toEqual([
+ resource('wf-gone'),
+ ])
+ })
+
+ it('keeps a workflow present in the fetched list', () => {
+ expect(selectDeletedWorkflowResources([resource('wf-1')], new Set(['wf-1']), [])).toEqual([])
+ })
+
+ it('keeps a workflow the stream inserted into the cache after the list snapshot', () => {
+ expect(
+ selectDeletedWorkflowResources([resource('wf-new')], new Set(), [cached('wf-new')])
+ ).toEqual([])
+ })
+})
+
+describe('shouldActivateResourceEvent', () => {
+ it('surfaces browser work even when another resource is selected', () => {
+ expect(shouldActivateResourceEvent('file-1', 'browser-session')).toBe(true)
+ })
+
+ it('surfaces every other resource the agent touches', () => {
+ expect(shouldActivateResourceEvent('file-1', 'workflow-1')).toBe(true)
+ expect(shouldActivateResourceEvent('browser-session', 'terminal-session')).toBe(true)
+ expect(shouldActivateResourceEvent(null, 'browser-session')).toBe(true)
+ })
+
+ it('honors an explicit request to activate', () => {
+ expect(shouldActivateResourceEvent('file-1', 'browser-session', { activate: true })).toBe(true)
+ })
+
+ it('lets an event opt out of stealing focus', () => {
+ expect(shouldActivateResourceEvent('file-1', 'browser-session', { activate: false })).toBe(
+ false
+ )
+ })
+})
+
+describe('shouldQueueOutgoingMessage', () => {
+ it('queues while a send is in flight', () => {
+ expect(shouldQueueOutgoingMessage(true, false, 0)).toBe(true)
+ })
+
+ it('queues while a stop is still settling', () => {
+ expect(shouldQueueOutgoingMessage(false, true, 0)).toBe(true)
+ })
+
+ it('queues behind messages still waiting after the turn ended', () => {
+ // The regression: a message queued mid-stream must dispatch before one
+ // typed in the idle gap after the turn stopped — a direct send here would
+ // jump the queue and swap the user's message order.
+ expect(shouldQueueOutgoingMessage(false, false, 1)).toBe(true)
+ })
+
+ it('sends directly on an idle chat with an empty queue', () => {
+ expect(shouldQueueOutgoingMessage(false, false, 0)).toBe(false)
+ })
+})
+
function userMessage(id: string): PersistedMessage {
return {
id,
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
index acbea0bd61..b3e422d532 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
@@ -30,7 +30,7 @@ import { onOpenInBrowserPanel } from '@/lib/browser-agent/open-in-panel'
import {
cancelActiveBrowserTools,
initBrowserAgentTransport,
- sendBrowserPanelAction,
+ openUrlInNewBrowserTab,
} from '@/lib/browser-agent/transport'
import { getMothershipAttachmentPreviewUrl } from '@/lib/copilot/chat/attachment-preview'
import { toDisplayMessage } from '@/lib/copilot/chat/display-message'
@@ -126,8 +126,10 @@ import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
import { invalidateWorkflowSelectors } from '@/hooks/queries/utils/invalidate-workflow-lists'
import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order'
import { getWorkflowById, getWorkflows } from '@/hooks/queries/utils/workflow-cache'
+import { getWorkflowListQueryOptions } from '@/hooks/queries/utils/workflow-list-query'
import { workflowKeys } from '@/hooks/queries/workflows'
import { useExecutionStream } from '@/hooks/use-execution-stream'
+import { snapAllSmoothText } from '@/hooks/use-smooth-text'
import { useExecutionStore } from '@/stores/execution/store'
import { useMothershipQueueStore } from '@/stores/mothership-queue/store'
import type {
@@ -228,6 +230,7 @@ const RECONNECT_TAIL_ERROR =
const MAX_RECONNECT_ATTEMPTS = 10
const RECONNECT_BASE_DELAY_MS = 1000
const RECONNECT_MAX_DELAY_MS = 30_000
+const RECONNECT_EXHAUSTED_RECHECK_MS = 30_000
const STREAM_BATCH_FETCH_TIMEOUT_MS = 10_000
const STREAM_CHAT_ID_RESOLVE_TIMEOUT_MS = 10_000
const CHAT_HISTORY_RECOVERY_TIMEOUT_MS = 10_000
@@ -1188,8 +1191,65 @@ function ensureWorkflowInRegistry(resourceId: string, title: string, workspaceId
return true
}
+/**
+ * Hydrated workflow resources whose workflow exists neither in the fetched
+ * server list nor in the local cache. The cache term protects a workflow the
+ * agent created after the list snapshot was taken — the stream's registry
+ * insert lands it in the cache before any refetch does.
+ */
+export function selectDeletedWorkflowResources(
+ workflowResources: MothershipResource[],
+ fetchedWorkflowIds: ReadonlySet,
+ cachedWorkflows: readonly WorkflowMetadata[]
+): MothershipResource[] {
+ const cachedIds = new Set(cachedWorkflows.map((workflow) => workflow.id))
+ return workflowResources.filter(
+ (resource) => !fetchedWorkflowIds.has(resource.id) && !cachedIds.has(resource.id)
+ )
+}
+
+export interface ResourceEventOptions {
+ activate?: boolean
+}
+
+export type ResourceEventHandler = (resourceId: string, options?: ResourceEventOptions) => void
+
+/**
+ * Whether a streamed resource event should activate its tab. The panel always
+ * follows the agent: whatever it is creating, editing, or driving becomes the
+ * visible resource, browser sessions included. The parameters are retained so
+ * callers stay explicit about the resource in play, and so a future opt-out
+ * (an event that deliberately declines focus) has a place to live.
+ */
+export function shouldActivateResourceEvent(
+ _activeResourceId: string | null,
+ _resourceId: string,
+ options?: ResourceEventOptions
+): boolean {
+ return options?.activate !== false
+}
+
+/**
+ * Whether a fresh outbound message must join the chat's send queue instead of
+ * dispatching directly. Queueing while a send or stop is in flight is the
+ * obvious half; the queued-ahead term preserves FIFO across the
+ * streaming→idle boundary — a message queued while the previous turn streamed
+ * must reach the model before one typed after that turn ended but before the
+ * queue drained. Without it the fresh send jumps the queue and both the
+ * transcript and the model see the user's messages in swapped order. The two
+ * signals never gap mid-dispatch: a queued message stays in the queue until
+ * its optimistic send applies, which is after the in-flight flag is set.
+ */
+export function shouldQueueOutgoingMessage(
+ sendInFlight: boolean,
+ stopPending: boolean,
+ queuedAheadCount: number
+): boolean {
+ return sendInFlight || stopPending || queuedAheadCount > 0
+}
+
export interface UseChatOptions {
- onResourceEvent?: (resourceId: string) => void
+ onResourceEvent?: ResourceEventHandler
apiPath?: string
stopPath?: string
workflowId?: string
@@ -1437,6 +1497,10 @@ export function useChat(
() => {}
)
const recoveringQueuedSendHandoffRef = useRef(null)
+ const recoverActiveStreamRef = useRef<
+ (reason: 'pageshow' | 'visible' | 'online' | 'exhausted_recheck') => Promise
+ >(async () => {})
+ const reconnectExhaustedRecheckTimerRef = useRef | null>(null)
const abortControllerRef = useRef(null)
const detachedChatResolutionControllersRef = useRef>(new Set())
@@ -1814,6 +1878,37 @@ export function useChat(
}
}, [])
+ /**
+ * Drops hydrated workflow tabs whose workflow no longer exists, so an old
+ * chat cannot resurrect a deleted workflow. The check is against a fetched
+ * workflow list rather than the cache: seeding the registry from the chat's
+ * persisted resources (what hydration previously did unconditionally) put
+ * phantom entries in the sidebar that 404 on click. Removal also deletes the
+ * resource from the chat's persisted set, so the tab stays gone next open.
+ */
+ const reconcileHydratedWorkflowResources = useCallback(
+ async (chatId: string, workflowResources: MothershipResource[]) => {
+ let existing: WorkflowMetadata[]
+ try {
+ existing = await getQueryClient().fetchQuery(getWorkflowListQueryOptions(workspaceId))
+ } catch {
+ // Existence is unknowable right now; keep the tabs rather than delete
+ // resources on a network failure. The next hydration retries.
+ return
+ }
+ const deleted = selectDeletedWorkflowResources(
+ workflowResources,
+ new Set(existing.map((workflow) => workflow.id)),
+ getWorkflows(workspaceId)
+ )
+ for (const resource of deleted) {
+ if ((chatIdRef.current ?? selectedChatIdRef.current) !== chatId) return
+ removeResource('workflow', resource.id)
+ }
+ },
+ [workspaceId, removeResource]
+ )
+
const reorderResources = useCallback((newOrder: MothershipResource[]) => {
setResources(newOrder)
const persistChatId = chatIdRef.current ?? selectedChatIdRef.current
@@ -1924,12 +2019,14 @@ export function useChat(
)
const openBrowserResource = useCallback(() => {
+ // Browser work surfaces like any other agent activity: the panel follows
+ // the agent to the browser whether or not the session was already open.
addResource({
type: 'browser',
id: BROWSER_SESSION_RESOURCE_ID,
title: 'Browser',
})
- onResourceEventRef.current?.(BROWSER_SESSION_RESOURCE_ID)
+ onResourceEventRef.current?.(BROWSER_SESSION_RESOURCE_ID, { activate: true })
}, [addResource])
const getResourceActivityTracker = useCallback(
@@ -2047,7 +2144,11 @@ export function useChat(
useEffect(() => {
return onOpenInBrowserPanel((url) => {
openBrowserResource()
- sendBrowserPanelAction('navigate', { url }, desktopScopeIdRef.current)
+ void openUrlInNewBrowserTab(url, desktopScopeIdRef.current).catch((error) => {
+ logger.warn('Failed to open chat link in a new browser tab', {
+ error: getErrorMessage(error),
+ })
+ })
})
}, [openBrowserResource])
@@ -2355,9 +2456,12 @@ export function useChat(
setActiveResourceId(hydratedActiveResourceId)
}
- for (const resource of persistedResources) {
- if (resource.type !== 'workflow') continue
- ensureWorkflowInRegistry(resource.id, resource.title, workspaceId)
+ // Restored workflow tabs are verified against the server instead of
+ // seeded into the registry: a chat can outlive its workflows, and
+ // fabricating entries for deleted ones polluted the sidebar.
+ const workflowResources = persistedResources.filter((r) => r.type === 'workflow')
+ if (workflowResources.length > 0) {
+ void reconcileHydratedWorkflowResources(chatHistory.id, workflowResources)
}
} else if (hasPersistedStreamingFile) {
activeResourceIdRef.current = null
@@ -2442,6 +2546,7 @@ export function useChat(
flushPendingResources,
openBrowserResource,
openTerminalResource,
+ reconcileHydratedWorkflowResources,
recoverPendingClientWorkflowTools,
seedPreviewSessions,
setTransportIdle,
@@ -3199,7 +3304,29 @@ export function useChat(
maxAttempts: MAX_RECONNECT_ATTEMPTS,
})
if (streamGenRef.current === gen) {
+ /**
+ * Never give up silently: surface the failure so the pane shows why
+ * the live stream stopped instead of a torn-down transcript. Callers
+ * own the finalize on a false return (every call site finalizes with
+ * error: true), which refetches the persisted transcript; if the
+ * server turn is still running, the visibility/online recovery path
+ * re-attaches on the next pageshow/visible/online event.
+ */
setIsReconnecting(false)
+ setError(RECONNECT_TAIL_ERROR)
+ /**
+ * The tab may stay visible (no pageshow/visible/online event will ever
+ * fire) while the server turn keeps running detached. One bounded
+ * recheck re-enters recovery once the transient network condition has
+ * had time to clear; recovery itself no-ops when nothing is active.
+ */
+ if (reconnectExhaustedRecheckTimerRef.current) {
+ clearTimeout(reconnectExhaustedRecheckTimerRef.current)
+ }
+ reconnectExhaustedRecheckTimerRef.current = setTimeout(() => {
+ reconnectExhaustedRecheckTimerRef.current = null
+ void recoverActiveStreamRef.current('exhausted_recheck')
+ }, RECONNECT_EXHAUSTED_RECHECK_MS)
}
return false
},
@@ -3208,7 +3335,7 @@ export function useChat(
retryReconnectRef.current = retryReconnect
const recoverActiveStreamFromRedis = useCallback(
- async (reason: 'pageshow' | 'visible' | 'online'): Promise => {
+ async (reason: 'pageshow' | 'visible' | 'online' | 'exhausted_recheck'): Promise => {
const startingChatId = chatIdRef.current
const startingSelectedChatId = selectedChatIdRef.current
const chatId = startingChatId ?? startingSelectedChatId
@@ -3343,6 +3470,7 @@ export function useChat(
},
[getActiveStreamIdForChat, queryClient, resumeOrFinalize, setTransportReconnecting]
)
+ recoverActiveStreamRef.current = recoverActiveStreamFromRedis
useEffect(() => {
if (typeof window === 'undefined' || typeof document === 'undefined') return
@@ -3374,6 +3502,10 @@ export function useChat(
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('pageshow', handlePageShow)
window.removeEventListener('online', handleOnline)
+ if (reconnectExhaustedRecheckTimerRef.current) {
+ clearTimeout(reconnectExhaustedRecheckTimerRef.current)
+ reconnectExhaustedRecheckTimerRef.current = null
+ }
}
}, [recoverActiveStreamFromRedis])
@@ -4164,12 +4296,23 @@ export function useChat(
// An in-flight send drains the queue from `finalize`; a pending stop kicks
// the dispatcher itself, since nothing else will once the stop settles.
- if (sendingRef.current || pendingStopPromiseRef.current) {
+ // A non-empty queue forces queueing even on an idle chat: messages
+ // queued while the previous turn streamed must go out first, so a fresh
+ // send lands behind them instead of jumping the line in the drain gap
+ // after a turn ends.
+ const queuedAheadCount = (queueStore.queues[activeChatKey] ?? EMPTY_MESSAGE_QUEUE).length
+ if (
+ shouldQueueOutgoingMessage(
+ Boolean(sendingRef.current),
+ Boolean(pendingStopPromiseRef.current),
+ queuedAheadCount
+ )
+ ) {
queueStore.enqueue(
activeChatKey,
createQueuedMessage(message, fileAttachments, contexts, options?.resumeUserMessageId)
)
- if (pendingStopPromiseRef.current) {
+ if (pendingStopPromiseRef.current || (queuedAheadCount > 0 && !sendingRef.current)) {
void enqueueQueueDispatchRef.current({ type: 'send_head' })
}
return
@@ -4518,6 +4661,9 @@ export function useChat(
abortControllerRef.current?.abort('user_stop:client_stopGeneration')
abortControllerRef.current = null
setTransportIdle()
+ // The paced reveal may still hold up to a drain-horizon of buffered text;
+ // after an explicit Stop it must not keep typing itself out.
+ snapAllSmoothText()
try {
if (activeChatId) {
diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts
index e6d21c2776..9634055b5e 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/types.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts
@@ -1,7 +1,7 @@
import type { ChatContext } from '@/stores/panel'
import type { BrowserTextSelection, TerminalTextSelection } from '@/stores/panel/types'
-const EDIT_CONTENT_TOOL_ID = 'edit_content'
+const EDIT_CONTENT_TOOL_ID = 'apply_file_edit'
const RUN_SUBAGENT_ID = 'run'
export type {
@@ -114,6 +114,8 @@ export interface ContentBlock {
type: ContentBlockType
content?: string
subagent?: string
+ /** Orchestrator-chosen display name for a `subagent` start block (shown instead of the generic agent label). */
+ subagentName?: string
toolCall?: ToolCallInfo
options?: OptionItem[]
timestamp?: number
@@ -189,9 +191,13 @@ export const SUBAGENT_LABELS: Record = {
custom_tool: 'Custom Tool Agent',
scout: 'Scout Agent',
search: 'Search Agent',
+ platform: 'Platform Agent',
superagent: 'Superagent',
run: 'Run Agent',
- agent: 'Tools Agent',
+ // The extensions subagent's wire/scope AgentID stays `agent` (pre-rename);
+ // `extensions` is its current model-facing trigger tool name.
+ agent: 'Extensions Agent',
+ extensions: 'Extensions Agent',
// `job` retained as a backward-compat alias so historical transcripts still render a label.
job: 'Job Agent',
file: 'File Agent',
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx
index 449e1dbe83..cf0a76bcac 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx
@@ -29,6 +29,7 @@ import {
SLACK_CAPABILITIES,
SLACK_MANAGED_USER_AUTHORIZATION_CAPABILITY,
} from '@/triggers/slack/capabilities'
+import { buildSlackCustomBotRequestUrl } from '@/triggers/webhook-url'
const logger = createLogger('ConnectSlackBotModal')
@@ -119,13 +120,9 @@ export function ConnectSlackBotModal({
}
}, [open, created, isReconnect, initialDisplayName, initialDescription])
- // NEXT_PUBLIC_APP_URL, not window.location.origin: Slack's servers must be
- // able to reach this URL, so it has to be the app's public base (e.g. the
- // tunnel host in dev), not whatever host the browser happens to be on.
- const requestUrl = useMemo(
- () => `${getBaseUrl()}/api/webhooks/slack/custom/${credentialId}`,
- [credentialId]
- )
+ // Shared server-side derivation: uses the app public base (not
+ // window.location.origin) so Slack's servers can reach it.
+ const requestUrl = useMemo(() => buildSlackCustomBotRequestUrl(credentialId), [credentialId])
const manifestJson = useMemo(() => {
const managedUserAuthorization = selected.has(SLACK_MANAGED_USER_AUTHORIZATION_CAPABILITY.id)
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx
index 3f2d482dbe..3edc072014 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx
@@ -37,6 +37,7 @@ import {
EMPTY_CELL_PLACEHOLDER,
Resource,
ResourceNotFound,
+ SearchHighlight,
} from '@/app/workspace/[workspaceId]/components'
import {
FOLDERED_RESOURCE_HEADERS,
@@ -55,7 +56,7 @@ import {
documentParsers,
documentUrlKeys,
} from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params'
-import { ActionBar, SearchHighlight } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
+import { ActionBar } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx
index 6d61db64ea..95c2e69871 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx
@@ -64,6 +64,7 @@ import {
isResourceListEmpty,
Resource,
ResourceNotFound,
+ SearchHighlight,
} from '@/app/workspace/[workspaceId]/components'
import {
FOLDERED_RESOURCE_HEADERS,
@@ -81,7 +82,6 @@ import {
ConnectorsSection,
DocumentContextMenu,
RenameDocumentModal,
- SearchHighlight,
} from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
import {
addConnectorParam,
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts
index 12e32ebf73..d26e85dc9e 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts
@@ -6,4 +6,3 @@ export { ConnectorsSection } from './connectors-section'
export { DocumentContextMenu } from './document-context-menu'
export { EditConnectorModal } from './edit-connector-modal'
export { RenameDocumentModal } from './rename-document-modal'
-export { SearchHighlight } from './search-highlight'
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/search-highlight/index.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/search-highlight/index.ts
deleted file mode 100644
index 1144ed165c..0000000000
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/search-highlight/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { SearchHighlight } from './search-highlight'
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx
index 327771b3a5..bb3e1a1109 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx
@@ -87,8 +87,22 @@ vi.mock('@sim/emcn', () => ({
),
Label: ({ children }: { children: ReactNode }) => {children} ,
- Switch: ({ checked }: { checked: boolean }) => (
-
+ Switch: ({
+ checked,
+ disabled,
+ onCheckedChange,
+ }: {
+ checked: boolean
+ disabled?: boolean
+ onCheckedChange?: (checked: boolean) => void
+ }) => (
+ onCheckedChange?.(!checked)}
+ />
),
toast: mockToast,
}))
@@ -235,6 +249,7 @@ const IMPORTED_BOTH: BrowserChromeImportResult = {
interface BridgeOverrides {
browserEnabled?: boolean
+ browserSearchSuggestionsEnabled?: boolean
browserTheme?: 'app' | 'light' | 'dark'
browserDefaultZoom?: DesktopZoomPercent
browserDownloadDirectory?: string
@@ -247,6 +262,7 @@ interface BridgeOverrides {
function createBridge({
browserEnabled = true,
+ browserSearchSuggestionsEnabled = true,
browserTheme = 'app',
browserDefaultZoom = 100,
browserDownloadDirectory = '/Users/sim/Downloads',
@@ -263,6 +279,7 @@ function createBridge({
launchAtLogin: false,
autoDownloadUpdates: true,
browserEnabled,
+ browserSearchSuggestionsEnabled,
browserTheme,
browserDefaultZoom,
browserDownloadDirectory,
@@ -271,6 +288,10 @@ function createBridge({
settings: {
getPreferences: vi.fn(async () => preferences),
setBrowserEnabled: vi.fn(),
+ setBrowserSearchSuggestionsEnabled: vi.fn(async (enabled: boolean) => ({
+ ...preferences,
+ browserSearchSuggestionsEnabled: enabled,
+ })),
setBrowserTheme: vi.fn(async (theme: 'app' | 'light' | 'dark') => ({
...preferences,
browserTheme: theme,
@@ -343,6 +364,7 @@ describe('Browser settings', () => {
expect(container.textContent).toContain('Let Chat browse the web')
expect(container.textContent).toContain('Theme')
+ expect(container.textContent).toContain('Search suggestions')
expect(container.textContent).not.toContain(
'Pages open in a browser built into Sim, signed in separately from your own.'
)
@@ -352,6 +374,22 @@ describe('Browser settings', () => {
expect(container.textContent).not.toContain('Saved passwords are never deleted here')
})
+ it('explains and persists the live-search privacy switch', async () => {
+ const bridge = createBridge({ browserSearchSuggestionsEnabled: true })
+ mockBridge.current = bridge
+ await render()
+
+ expect(container.textContent).toContain(
+ 'Send address-bar typing to Google for live completions'
+ )
+ const switches = [...container.querySelectorAll('[role="switch"]')]
+ expect(switches[1]).toHaveAttribute('aria-checked', 'true')
+
+ await click(switches[1])
+
+ expect(bridge.settings.setBrowserSearchSuggestionsEnabled).toHaveBeenCalledWith(false)
+ })
+
it('persists an independent website theme', async () => {
const bridge = createBridge({ browserTheme: 'app' })
mockBridge.current = bridge
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.tsx
index 356d8c12aa..0710de9a7c 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.tsx
@@ -114,6 +114,14 @@ export function Browser() {
setPreferences
)
+ const { pending: searchSuggestionsPending, mutate: setSearchSuggestionsEnabled } =
+ useDesktopPreferenceMutation(
+ async (bridge, enabled: boolean) =>
+ bridge.settings.setBrowserSearchSuggestionsEnabled?.(enabled),
+ 'Could not update browser search suggestions',
+ setPreferences
+ )
+
const clear = useCallback(async (kinds: readonly BrowserDataKind[]) => {
const desktop = getDesktopBridge()
if (!desktop) return
@@ -145,6 +153,9 @@ export function Browser() {
}
const enabled = preferences.browserEnabled
+ const supportsSearchSuggestions = Boolean(
+ getDesktopBridge()?.settings.setBrowserSearchSuggestionsEnabled
+ )
const target = confirming === 'all' ? null : confirming
return (
@@ -172,6 +183,23 @@ export function Browser() {
/>
+ {supportsSearchSuggestions && (
+
+
+
Search suggestions
+
+ Send address-bar typing to Google for live completions
+
+
+
void setSearchSuggestionsEnabled(checked)}
+ />
+
+ )}
+
Theme
{findOpen && (
-
view.id === initialViewId) : undefined
+ const viewToApply = pinnedView ?? defaultView
// `sort` rides the same host URL, so when the view id is inherited the
// sort beside it is too — not local work, and it must not suppress the
// default view's own sort.
const keep = inheritedParams ? { ...localWork(), sort: false } : localWork()
- if (defaultView) {
- appliedViewRevisionRef.current = getTableViewRevision(defaultView)
- setTableParams({ view: defaultView.id })
- preserveViewState(defaultView.id, keep)
- applyViewConfig(resolveTableViewConfig(tableData?.metadata, defaultView.config), keep)
- resolvePendingLayout(defaultView.id)
- flushPendingViewConfig(defaultView.id)
+ if (viewToApply) {
+ appliedViewRevisionRef.current = getTableViewRevision(viewToApply)
+ setTableParams({ view: viewToApply.id })
+ preserveViewState(viewToApply.id, keep)
+ applyViewConfig(resolveTableViewConfig(tableData?.metadata, viewToApply.config), keep)
+ resolvePendingLayout(viewToApply.id)
+ flushPendingViewConfig(viewToApply.id)
return
}
// No view to adopt. Deliberately does NOT apply an empty config — that
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts
index 3f888b2e19..4dc59961db 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts
@@ -1070,7 +1070,10 @@ export async function executeWorkflowWithFullLogging(
error: errorMessage,
httpStatus: response.status,
})
- throw new Error(errorMessage)
+ // Keep the status and code on the thrown error. Downgrading to a bare Error
+ // discarded both, so callers could not tell a Copilot binding rejection from
+ // any other 4xx — and the reason never reached the agent that could fix it.
+ throw new ExecutionStreamHttpError(errorMessage, response.status, errorCode)
}
if (!response.body) {
diff --git a/apps/sim/components/icons/document-icons.tsx b/apps/sim/components/icons/document-icons.tsx
index 6b669a44d6..cd4436318d 100644
--- a/apps/sim/components/icons/document-icons.tsx
+++ b/apps/sim/components/icons/document-icons.tsx
@@ -139,6 +139,26 @@ export function VideoIcon(props: SVGProps) {
)
}
+export function ChartFileIcon(props: SVGProps) {
+ return (
+
+
+
+
+
+
+ )
+}
+
export function HtmlIcon(props: SVGProps) {
return (
{
- const categories: Record = {}
+ const categories: Record = {}
for (const feature of filteredPlatformFeatures) {
if (!categories[feature.category]) {
categories[feature.category] = []
diff --git a/apps/sim/ee/access-control/utils/permission-check.test.ts b/apps/sim/ee/access-control/utils/permission-check.test.ts
index 8a9d502c80..b5eaa61049 100644
--- a/apps/sim/ee/access-control/utils/permission-check.test.ts
+++ b/apps/sim/ee/access-control/utils/permission-check.test.ts
@@ -84,6 +84,8 @@ import {
ModelNotAllowedError,
ProviderNotAllowedError,
PublicFileSharingNotAllowedError,
+ resolveUserAccessControlContext,
+ resolveVerifiedUserAccessControlContext,
SkillsNotAllowedError,
ToolNotAllowedError,
validateBlockType,
@@ -229,6 +231,126 @@ describe('getUserPermissionConfig (org + entitlement gating)', () => {
})
})
+describe('resolveUserAccessControlContext', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ resetDbChainMock()
+ mockGetAllowedIntegrationsFromEnv.mockReturnValue(null)
+ })
+
+ it('describes a personal workspace without changing the config-only result', async () => {
+ mockGetWorkspaceWithOwner.mockResolvedValue({ organizationId: null })
+
+ await expect(resolveUserAccessControlContext('user-123', 'workspace-1')).resolves.toEqual({
+ organizationId: null,
+ entitled: false,
+ permissionGroup: null,
+ config: null,
+ })
+ await expect(getUserPermissionConfig('user-123', 'workspace-1')).resolves.toBeNull()
+ })
+
+ it('returns the explicit governing group and its effective config', async () => {
+ setEnterpriseOrgWorkspace()
+ queueGroupResolution([
+ {
+ id: 'group-explicit',
+ name: 'Engineering',
+ config: { disableMcpTools: true },
+ isMember: true,
+ hasMembers: true,
+ },
+ ])
+
+ await expect(resolveUserAccessControlContext('user-123', 'workspace-1')).resolves.toEqual({
+ organizationId: 'org-1',
+ entitled: true,
+ permissionGroup: {
+ id: 'group-explicit',
+ name: 'Engineering',
+ resolution: 'explicit-member',
+ },
+ config: expect.objectContaining({ disableMcpTools: true }),
+ })
+ })
+
+ it('identifies an all-members governing group', async () => {
+ setEnterpriseOrgWorkspace()
+ queueGroupResolution([
+ {
+ id: 'group-all-members',
+ name: 'All workspace members',
+ config: { disableCustomTools: true },
+ isMember: false,
+ hasMembers: false,
+ },
+ ])
+
+ const context = await resolveUserAccessControlContext('user-123', 'workspace-1')
+
+ expect(context.permissionGroup).toEqual({
+ id: 'group-all-members',
+ name: 'All workspace members',
+ resolution: 'all-members',
+ })
+ })
+
+ it('uses a verified workspace organization without loading the workspace again', async () => {
+ mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true)
+ queueGroupResolution([
+ {
+ id: 'group-verified',
+ name: 'Verified group',
+ config: { disableSkills: true },
+ isMember: true,
+ hasMembers: true,
+ },
+ ])
+
+ const context = await resolveVerifiedUserAccessControlContext(
+ 'user-123',
+ 'workspace-1',
+ 'org-verified'
+ )
+
+ expect(mockGetWorkspaceWithOwner).not.toHaveBeenCalled()
+ expect(mockIsOrganizationOnEnterprisePlan).toHaveBeenCalledWith('org-verified')
+ expect(context).toMatchObject({
+ organizationId: 'org-verified',
+ entitled: true,
+ permissionGroup: {
+ id: 'group-verified',
+ resolution: 'explicit-member',
+ },
+ config: { disableSkills: true },
+ })
+ })
+
+ it('identifies the default group and preserves the environment allowlist', async () => {
+ setEnterpriseOrgWorkspace()
+ mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack'])
+ queueGroupResolution(
+ [],
+ [
+ {
+ id: 'group-default',
+ name: 'Organization default',
+ config: { allowedIntegrations: ['slack', 'github'] },
+ },
+ ]
+ )
+
+ const context = await resolveUserAccessControlContext('user-123', 'workspace-1')
+
+ expect(context.permissionGroup).toEqual({
+ id: 'group-default',
+ name: 'Organization default',
+ resolution: 'default',
+ })
+ expect(context.config?.allowedIntegrations).toEqual(['slack'])
+ })
+})
+
describe('getUserPermissionConfig (workspace-group precedence)', () => {
beforeEach(() => {
vi.clearAllMocks()
diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts
index d83b16e8f6..0afd24ed46 100644
--- a/apps/sim/ee/access-control/utils/permission-check.ts
+++ b/apps/sim/ee/access-control/utils/permission-check.ts
@@ -141,9 +141,30 @@ function mergeEnvAllowlist(config: PermissionGroupConfig | null): PermissionGrou
export interface ResolvedPermissionGroup {
permissionGroupId: string
groupName: string
+ resolution: 'explicit-member' | 'all-members' | 'default'
config: PermissionGroupConfig
}
+export interface UserAccessControlContext {
+ organizationId: string | null
+ entitled: boolean
+ permissionGroup: {
+ id: string
+ name: string
+ resolution: ResolvedPermissionGroup['resolution']
+ } | null
+ config: PermissionGroupConfig | null
+}
+
+function inactiveUserAccessControlContext(organizationId: string | null): UserAccessControlContext {
+ return {
+ organizationId,
+ entitled: false,
+ permissionGroup: null,
+ config: mergeEnvAllowlist(null),
+ }
+}
+
/** The organization's single default group (`isDefault`), or `null`. */
async function resolveDefaultGroup(
organizationId: string
@@ -167,6 +188,7 @@ async function resolveDefaultGroup(
return {
permissionGroupId: defaultGroup.id,
groupName: defaultGroup.name,
+ resolution: 'default',
config: parsePermissionGroupConfig(defaultGroup.config),
}
}
@@ -222,12 +244,14 @@ export async function resolveWorkspaceGroup(
)
.orderBy(asc(permissionGroup.createdAt), asc(permissionGroup.id))
- const winner = rows.find((row) => row.isMember) ?? rows.find((row) => !row.hasMembers)
+ const explicitMemberGroup = rows.find((row) => row.isMember)
+ const winner = explicitMemberGroup ?? rows.find((row) => !row.hasMembers)
if (winner) {
return {
permissionGroupId: winner.id,
groupName: winner.name,
+ resolution: explicitMemberGroup ? 'explicit-member' : 'all-members',
config: parsePermissionGroupConfig(winner.config),
}
}
@@ -246,26 +270,70 @@ export async function resolveWorkspaceGroup(
* The env-level integration allowlist is always merged last so self-hosted
* deployments can constrain integrations without touching the DB.
*/
+async function resolveUserAccessControlContextForOrganization(
+ userId: string,
+ workspaceId: string,
+ organizationId: string | null
+): Promise {
+ if (!organizationId) return inactiveUserAccessControlContext(null)
+
+ const isEnterprise = await isOrganizationOnEnterprisePlan(organizationId)
+ if (!isEnterprise) {
+ return inactiveUserAccessControlContext(organizationId)
+ }
+
+ const resolved = await resolveWorkspaceGroup(userId, organizationId, workspaceId)
+ return {
+ organizationId,
+ entitled: true,
+ permissionGroup: resolved
+ ? {
+ id: resolved.permissionGroupId,
+ name: resolved.groupName,
+ resolution: resolved.resolution,
+ }
+ : null,
+ config: mergeEnvAllowlist(resolved?.config ?? null),
+ }
+}
+
+/**
+ * Resolves Access Control from an organization ID obtained from an already
+ * access-checked workspace. This function does not independently authorize the
+ * user for the workspace; callers must establish that boundary first.
+ */
+export async function resolveVerifiedUserAccessControlContext(
+ userId: string,
+ workspaceId: string,
+ organizationId: string | null
+): Promise {
+ if (!isHosted && !isAccessControlEnabled) {
+ return inactiveUserAccessControlContext(null)
+ }
+ return resolveUserAccessControlContextForOrganization(userId, workspaceId, organizationId)
+}
+
+export async function resolveUserAccessControlContext(
+ userId: string,
+ workspaceId: string
+): Promise {
+ if (!isHosted && !isAccessControlEnabled) {
+ return inactiveUserAccessControlContext(null)
+ }
+
+ const workspace = await getWorkspaceWithOwner(workspaceId, { includeArchived: true })
+ return resolveUserAccessControlContextForOrganization(
+ userId,
+ workspaceId,
+ workspace?.organizationId ?? null
+ )
+}
+
export async function getUserPermissionConfig(
userId: string,
workspaceId: string
): Promise {
- if (!isHosted && !isAccessControlEnabled) {
- return mergeEnvAllowlist(null)
- }
-
- const ws = await getWorkspaceWithOwner(workspaceId, { includeArchived: true })
- if (!ws?.organizationId) {
- return mergeEnvAllowlist(null)
- }
-
- const isEnterprise = await isOrganizationOnEnterprisePlan(ws.organizationId)
- if (!isEnterprise) {
- return mergeEnvAllowlist(null)
- }
-
- const resolved = await resolveWorkspaceGroup(userId, ws.organizationId, workspaceId)
- return mergeEnvAllowlist(resolved?.config ?? null)
+ return (await resolveUserAccessControlContext(userId, workspaceId)).config
}
/**
diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts
index 4d54cfb8bb..6ee390af92 100644
--- a/apps/sim/hooks/queries/tables.ts
+++ b/apps/sim/hooks/queries/tables.ts
@@ -501,6 +501,47 @@ export function useFindTableRows({ workspaceId, tableId, q, filter, sort }: Find
})
}
+/**
+ * Bounded single-page row read for chart files (`.chart` previews): one fetch
+ * of up to `limit` rows with the spec's verbatim filter/sort. Charts accept a
+ * truncated page — they visualize a sample, not a drained table.
+ */
+export function useTableRowsSample({
+ workspaceId,
+ tableId,
+ filter,
+ sort,
+ limit,
+ enabled = true,
+}: {
+ workspaceId?: string
+ tableId?: string
+ /** Passed through verbatim from the chart document; the contract validates. */
+ filter?: unknown
+ sort?: unknown
+ limit: number
+ enabled?: boolean
+}) {
+ const paramsKey = JSON.stringify({ filter: filter ?? null, sort: sort ?? null, limit })
+ return useQuery({
+ // rq-lint-allow: tableId is globally unique; workspaceId is only the authz scope
+ queryKey: tableKeys.sample(tableId ?? '', paramsKey),
+ queryFn: ({ signal }) =>
+ fetchTableRows({
+ workspaceId: workspaceId as string,
+ tableId: tableId as string,
+ limit,
+ filter: (filter ?? null) as TablePredicate | null,
+ sort: (sort ?? null) as SortSpec | null,
+ includeTotal: false,
+ signal,
+ }),
+ enabled: Boolean(workspaceId && tableId) && enabled,
+ staleTime: TABLE_ROWS_STALE_TIME,
+ placeholderData: keepPreviousData,
+ })
+}
+
export function tableRowsInfiniteOptions({
workspaceId,
tableId,
diff --git a/apps/sim/hooks/queries/utils/table-keys.ts b/apps/sim/hooks/queries/utils/table-keys.ts
index 853103b512..5ccf7f3445 100644
--- a/apps/sim/hooks/queries/utils/table-keys.ts
+++ b/apps/sim/hooks/queries/utils/table-keys.ts
@@ -34,6 +34,9 @@ export const tableKeys = {
infiniteRows: (tableId: string, paramsKey: string) =>
[...tableKeys.infiniteRowsRoot(tableId), paramsKey] as const,
rowWrites: (tableId: string) => [...tableKeys.rowsRoot(tableId), 'write'] as const,
+ /** Bounded single-page row read for chart files (`.chart` previews). */
+ sample: (tableId: string, paramsKey: string) =>
+ [...tableKeys.rowsRoot(tableId), 'sample', paramsKey] as const,
find: (tableId: string, paramsKey: string) =>
[...tableKeys.rowsRoot(tableId), 'find', paramsKey] as const,
/** Deliberately NOT under `detail` — the non-exact `invalidateQueries` on that
diff --git a/apps/sim/hooks/use-smooth-text.test.tsx b/apps/sim/hooks/use-smooth-text.test.tsx
index b4fe89d991..c80221ddce 100644
--- a/apps/sim/hooks/use-smooth-text.test.tsx
+++ b/apps/sim/hooks/use-smooth-text.test.tsx
@@ -4,7 +4,7 @@
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { useSmoothText } from '@/hooks/use-smooth-text'
+import { snapAllSmoothText, useSmoothText } from '@/hooks/use-smooth-text'
interface ProbeProps {
content: string
@@ -89,3 +89,37 @@ describe('useSmoothText — streaming that begins on an already-open document',
h.unmount()
})
})
+
+describe('snapAllSmoothText — user Stop must end the paced reveal instantly', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('reveals the full backlog immediately when snapped mid-stream', () => {
+ const probe = renderSmoothText({ content: '', isStreaming: true })
+ probe.rerender({ content: 'The quick brown fox jumps over the lazy dog. '.repeat(4) })
+ // Paced reveal has not caught up (fake timers hold the frame loop).
+ expect(probe.value().length).toBeLessThan(180)
+
+ act(() => {
+ snapAllSmoothText()
+ })
+ expect(probe.value()).toBe('The quick brown fox jumps over the lazy dog. '.repeat(4))
+ probe.unmount()
+ })
+
+ it('is one-shot: a later stream paces normally again', () => {
+ const probe = renderSmoothText({ content: '', isStreaming: true })
+ act(() => {
+ snapAllSmoothText()
+ })
+ probe.rerender({
+ content: 'Fresh streaming text that should reveal gradually, not snap. '.repeat(3),
+ })
+ expect(probe.value().length).toBeLessThan(180)
+ probe.unmount()
+ })
+})
diff --git a/apps/sim/hooks/use-smooth-text.ts b/apps/sim/hooks/use-smooth-text.ts
index 0411e8365d..0b38aa11b4 100644
--- a/apps/sim/hooks/use-smooth-text.ts
+++ b/apps/sim/hooks/use-smooth-text.ts
@@ -1,4 +1,29 @@
-import { useEffect, useRef, useState } from 'react'
+import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
+
+/**
+ * Global snap signal: bumping the epoch makes every mounted smooth-text reveal
+ * jump to its full content immediately. Used by the user Stop path — after an
+ * explicit abort, watching the buffered tail keep typing itself out reads as
+ * "my stop didn't work", so the paced reveal must end NOW, not over the drain
+ * horizon. One-shot per bump: subsequent streams pace normally again.
+ */
+let snapEpoch = 0
+const snapListeners = new Set<() => void>()
+
+function subscribeToSnapEpoch(listener: () => void): () => void {
+ snapListeners.add(listener)
+ return () => snapListeners.delete(listener)
+}
+
+function getSnapEpoch(): number {
+ return snapEpoch
+}
+
+/** Snap every mounted smooth-text reveal to its full content immediately. */
+export function snapAllSmoothText(): void {
+ snapEpoch++
+ for (const listener of [...snapListeners]) listener()
+}
/**
* Time-based paced reveal of a growing string. A per-frame loop earns a
@@ -111,8 +136,22 @@ export function useSmoothText(
const prevContentRef = useRef(content)
const prevIsStreamingRef = useRef(isStreaming)
+ const currentSnapEpoch = useSyncExternalStore(subscribeToSnapEpoch, getSnapEpoch, getSnapEpoch)
+ const [prevSnapEpoch, setPrevSnapEpoch] = useState(currentSnapEpoch)
+
let effectiveRevealed = revealed
+ // A user Stop bumped the snap epoch: reveal everything now instead of
+ // draining the backlog at the paced cadence.
+ if (prevSnapEpoch !== currentSnapEpoch) {
+ setPrevSnapEpoch(currentSnapEpoch)
+ if (revealed < content.length) {
+ effectiveRevealed = content.length
+ revealedRef.current = content.length
+ setRevealed(content.length)
+ }
+ }
+
if (
isStreaming &&
!prevIsStreamingRef.current &&
diff --git a/apps/sim/instrumentation-node.ts b/apps/sim/instrumentation-node.ts
index 9d536bb527..2b9da2a012 100644
--- a/apps/sim/instrumentation-node.ts
+++ b/apps/sim/instrumentation-node.ts
@@ -83,6 +83,23 @@ function normalizeOtlpMetricsUrl(url: string): string {
}
}
+// Logs counterpart to `normalizeOtlpMetricsUrl` — same parsed-pathname
+// handling, targeting the /v1/logs signal path.
+function normalizeOtlpLogsUrl(url: string): string {
+ if (!url) return url
+ try {
+ const u = new URL(url)
+ const path = u.pathname.replace(/\/$/, '')
+ if (path.endsWith('/v1/logs')) return url
+ u.pathname = path.endsWith('/v1/traces')
+ ? path.replace(/\/v1\/traces$/, '/v1/logs')
+ : `${path}/v1/logs`
+ return u.toString()
+ } catch {
+ return url
+ }
+}
+
// deployment.environment in the GO value space (dev | staging | prod) without
// any new infra env var. Every deployed Sim tier already gets
// APPCONFIG_ENVIRONMENT = the infra env name (dev | staging | production), so we
@@ -177,6 +194,8 @@ async function initializeOpenTelemetry() {
const { OTLPTraceExporter } = await import('@opentelemetry/exporter-trace-otlp-http')
const { OTLPMetricExporter } = await import('@opentelemetry/exporter-metrics-otlp-http')
const { PeriodicExportingMetricReader } = await import('@opentelemetry/sdk-metrics')
+ const { OTLPLogExporter } = await import('@opentelemetry/exporter-logs-otlp-http')
+ const { BatchLogRecordProcessor } = await import('@opentelemetry/sdk-logs')
const { BatchSpanProcessor } = await import('@opentelemetry/sdk-trace-node')
const { TraceIdRatioBasedSampler, SamplingDecision } = await import(
'@opentelemetry/sdk-trace-base'
@@ -271,6 +290,19 @@ async function initializeOpenTelemetry() {
exportIntervalMillis: 60000,
})
+ // Logs share the trace endpoint and headers as well (signal path
+ // /v1/logs). Every @sim/logger line fans out through the global Logs API
+ // (see packages/logger), which the NodeSDK wires to this processor — the
+ // stdout JSON lines continue to CloudWatch unchanged.
+ const logRecordProcessor = new BatchLogRecordProcessor(
+ new OTLPLogExporter({
+ url: normalizeOtlpLogsUrl(telemetryConfig.endpoint),
+ headers: otlpHeaders,
+ timeoutMillis: Math.min(telemetryConfig.batchSettings.exportTimeoutMillis, 10000),
+ keepAlive: false,
+ })
+ )
+
// Must be unique per process: replicas sharing one instance id collapse
// into a single Prometheus series, so their independent cumulative
// counters interleave and corrupt rate()/increase(). The slug keeps Sim
@@ -320,6 +352,7 @@ async function initializeOpenTelemetry() {
spanProcessors,
sampler,
metricReader,
+ logRecordProcessors: [logRecordProcessor],
})
sdk.start()
diff --git a/apps/sim/lib/api/contracts/copilot.ts b/apps/sim/lib/api/contracts/copilot.ts
index bd26d8a4a4..7a46337b03 100644
--- a/apps/sim/lib/api/contracts/copilot.ts
+++ b/apps/sim/lib/api/contracts/copilot.ts
@@ -140,6 +140,28 @@ export const copilotChatAbortBodySchema = z.object({
})
export type CopilotChatAbortBody = z.input
+export const copilotChatSteerBodySchema = z.object({
+ streamId: z.string().min(1, 'streamId is required'),
+ chatId: z.string().min(1, 'chatId is required'),
+ steeringId: z.string().min(1, 'steeringId is required'),
+ content: z.string().min(1, 'content is required').max(32_768, 'content is too long'),
+})
+export type CopilotChatSteerBody = z.input
+
+export const copilotToolExecuteInternalBodySchema = z.object({
+ toolCallId: z.string().min(1, 'toolCallId is required'),
+ toolName: z.string().min(1, 'toolName is required'),
+ params: z.record(z.string(), z.unknown()).default({}),
+ userId: z.string().min(1, 'userId is required'),
+ workflowId: z.string().optional(),
+ workspaceId: z.string().optional(),
+ chatId: z.string().optional(),
+ messageId: z.string().optional(),
+ parentToolCallId: z.string().optional(),
+ userPermission: z.string().optional(),
+})
+export type CopilotToolExecuteInternalBody = z.input
+
export const copilotChatGetQuerySchema = z
.object({
workflowId: z.string().optional(),
diff --git a/apps/sim/lib/api/contracts/custom-blocks.ts b/apps/sim/lib/api/contracts/custom-blocks.ts
index 70f40a1132..99ec47a5f0 100644
--- a/apps/sim/lib/api/contracts/custom-blocks.ts
+++ b/apps/sim/lib/api/contracts/custom-blocks.ts
@@ -83,7 +83,7 @@ export const listCustomBlocksQuerySchema = z.object({
* Icon URLs are rendered as org-wide ` ` sources, so only https URLs and
* internal file-serve paths (what the icon upload UI stores) are accepted —
* never data:/blob:/other schemes an admin could smuggle into shared metadata.
- * Shared with the copilot deploy_custom_block handler's pass-through branch.
+ * Shared with the copilot publish_custom_block handler's pass-through branch.
*/
export function isAllowedCustomBlockIconUrl(value: string): boolean {
return value.startsWith('https://') || value.startsWith('/api/files/serve/')
diff --git a/apps/sim/lib/api/contracts/logs.ts b/apps/sim/lib/api/contracts/logs.ts
index b269d8c75f..71677a738c 100644
--- a/apps/sim/lib/api/contracts/logs.ts
+++ b/apps/sim/lib/api/contracts/logs.ts
@@ -38,6 +38,8 @@ export const listLogsQuerySchema = logFilterQuerySchema.extend({
limit: z.coerce.number().int().min(1).max(200).optional().default(100),
sortBy: logSortBySchema,
sortOrder: logSortOrderSchema,
+ /** Also run a COUNT(*) under the same filters and return it as `total`. */
+ includeTotal: z.coerce.boolean().optional(),
})
export const logDetailQuerySchema = z.object({
@@ -294,6 +296,8 @@ export type WorkflowLogRow = WorkflowLogSummary &
export const listLogsResponseSchema = z.object({
data: z.array(workflowLogSummarySchema),
nextCursor: z.string().nullable(),
+ /** Total rows matching the filters; present only when `includeTotal` was set. */
+ total: z.number().optional(),
})
export type ListLogsResponse = z.output
diff --git a/apps/sim/lib/api/contracts/organization.ts b/apps/sim/lib/api/contracts/organization.ts
index 1a3ee14895..53d2644b4c 100644
--- a/apps/sim/lib/api/contracts/organization.ts
+++ b/apps/sim/lib/api/contracts/organization.ts
@@ -1,5 +1,6 @@
import { z } from 'zod'
import {
+ organizationRoleSchema,
type PiiRedactionSettings,
piiRedactionSettingsSchema,
retentionOverridesSchema,
@@ -15,9 +16,6 @@ const numericResponseSchema = z.preprocess((value) => {
return Number.isFinite(parsed) ? parsed : value
}, z.number())
-export const organizationRoleSchema = z.enum(['owner', 'admin', 'member'], {
- error: 'Invalid role',
-})
export const organizationParamsSchema = z.object({
id: z.string().min(1),
})
diff --git a/apps/sim/lib/api/contracts/primitives.test.ts b/apps/sim/lib/api/contracts/primitives.test.ts
index b9a2d79197..280df88de6 100644
--- a/apps/sim/lib/api/contracts/primitives.test.ts
+++ b/apps/sim/lib/api/contracts/primitives.test.ts
@@ -8,6 +8,7 @@ import {
isCanonicalBase64,
MAX_ID_LENGTH,
organizationIdSchema,
+ organizationRoleSchema,
piiStagePolicySchema,
piiStagesSchema,
privateSecretProvenanceBundleSchema,
@@ -19,6 +20,16 @@ import {
workspaceIdSchema,
} from '@/lib/api/contracts/primitives'
+describe('organizationRoleSchema', () => {
+ it.each(['owner', 'admin', 'member'] as const)('accepts canonical role %s', (role) => {
+ expect(organizationRoleSchema.parse(role)).toBe(role)
+ })
+
+ it.each(['billing-owner', 'viewer', '', null, undefined])('rejects invalid role %j', (role) => {
+ expect(organizationRoleSchema.safeParse(role).success).toBe(false)
+ })
+})
+
describe('workspaceFileNameSchema', () => {
it('trims and accepts one bounded file name', () => {
expect(workspaceFileNameSchema.parse(' report.pdf ')).toBe('report.pdf')
diff --git a/apps/sim/lib/api/contracts/primitives.ts b/apps/sim/lib/api/contracts/primitives.ts
index c2eeec7228..21717e0418 100644
--- a/apps/sim/lib/api/contracts/primitives.ts
+++ b/apps/sim/lib/api/contracts/primitives.ts
@@ -277,6 +277,12 @@ export const workspaceFileNameSchema = z
/** Non-empty `organizationId` field with a stable, human-readable message. */
export const organizationIdSchema = requiredFieldSchema('Organization ID is required')
+/** Canonical organization membership role shared across API resource families. */
+export const organizationRoleSchema = z.enum(['owner', 'admin', 'member'], {
+ error: 'Invalid role',
+})
+export type OrganizationRole = z.output
+
/** Non-empty `workflowId` field with a stable, human-readable message. */
export const workflowIdSchema = requiredFieldSchema('Workflow ID is required')
diff --git a/apps/sim/lib/api/contracts/workspaces.test.ts b/apps/sim/lib/api/contracts/workspaces.test.ts
new file mode 100644
index 0000000000..8adfeed25e
--- /dev/null
+++ b/apps/sim/lib/api/contracts/workspaces.test.ts
@@ -0,0 +1,32 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import { workspaceHostContextSchema } from '@/lib/api/contracts/workspaces'
+
+const viewerSchema = workspaceHostContextSchema.shape.viewer
+const viewer = {
+ permission: 'read' as const,
+ isHostOrganizationMember: false,
+ isHostOrganizationAdmin: false,
+}
+
+describe('workspaceHostContextSchema organizationRole', () => {
+ it.each(['owner', 'admin', 'member'] as const)(
+ 'accepts canonical role %s',
+ (organizationRole) => {
+ expect(viewerSchema.safeParse({ ...viewer, organizationRole }).success).toBe(true)
+ }
+ )
+
+ it('retains null and omission for rolling response compatibility', () => {
+ expect(viewerSchema.safeParse({ ...viewer, organizationRole: null }).success).toBe(true)
+ expect(viewerSchema.safeParse(viewer).success).toBe(true)
+ })
+
+ it('rejects non-canonical organization roles', () => {
+ expect(viewerSchema.safeParse({ ...viewer, organizationRole: 'billing-owner' }).success).toBe(
+ false
+ )
+ })
+})
diff --git a/apps/sim/lib/api/contracts/workspaces.ts b/apps/sim/lib/api/contracts/workspaces.ts
index 5c01d3fc62..beac7ce329 100644
--- a/apps/sim/lib/api/contracts/workspaces.ts
+++ b/apps/sim/lib/api/contracts/workspaces.ts
@@ -1,5 +1,9 @@
import { z } from 'zod'
-import { nonEmptyIdSchema, requiredFieldSchema } from '@/lib/api/contracts/primitives'
+import {
+ nonEmptyIdSchema,
+ organizationRoleSchema,
+ requiredFieldSchema,
+} from '@/lib/api/contracts/primitives'
import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types'
export const workspaceScopeSchema = z.enum(['active', 'archived', 'all'])
@@ -262,6 +266,8 @@ export const workspaceHostContextSchema = z.object({
permission: workspacePermissionSchema,
isHostOrganizationMember: z.boolean(),
isHostOrganizationAdmin: z.boolean(),
+ /** Optional for rolling compatibility with app versions that predate organization-role projection. */
+ organizationRole: organizationRoleSchema.nullable().optional(),
}),
features: z
.object({
diff --git a/apps/sim/lib/auth/sso-trust.test.ts b/apps/sim/lib/auth/sso-trust.test.ts
index 1b620d4529..bedc390b4c 100644
--- a/apps/sim/lib/auth/sso-trust.test.ts
+++ b/apps/sim/lib/auth/sso-trust.test.ts
@@ -10,6 +10,11 @@
import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing'
import { afterAll, expect, it, vi } from 'vitest'
+// Structurally slow — it imports the entire Better Auth module graph — so under a fully-parallel local run this file
+// blows the default timeout while passing in isolation and on CI. Give it a
+// real budget instead of letting machine load decide the verdict.
+vi.setConfig({ testTimeout: 30_000 })
+
const { ssoOptions } = vi.hoisted(() => ({
ssoOptions: { current: undefined as Record | undefined },
}))
diff --git a/apps/sim/lib/billing/core/account-billing-snapshot.test.ts b/apps/sim/lib/billing/core/account-billing-snapshot.test.ts
new file mode 100644
index 0000000000..503d01834a
--- /dev/null
+++ b/apps/sim/lib/billing/core/account-billing-snapshot.test.ts
@@ -0,0 +1,100 @@
+/**
+ * @vitest-environment node
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ events: [] as string[],
+ getResolvedUserUsageData: vi.fn(),
+ getCreditBalanceForEntity: vi.fn(),
+ isOrgScopedSubscription: vi.fn(),
+}))
+
+vi.mock('@/lib/billing/core/usage', () => ({
+ getResolvedUserUsageData: mocks.getResolvedUserUsageData,
+}))
+
+vi.mock('@/lib/billing/credits/balance', () => ({
+ getCreditBalanceForEntity: mocks.getCreditBalanceForEntity,
+}))
+
+vi.mock('@/lib/billing/subscriptions/utils', () => ({
+ isOrgScopedSubscription: mocks.isOrgScopedSubscription,
+}))
+
+import { getAccountBillingSnapshot } from '@/lib/billing/core/account-billing-snapshot'
+
+const usage = {
+ currentUsage: 18.5,
+ limit: 40,
+ percentUsed: 46.25,
+ isWarning: false,
+ isExceeded: false,
+ billingPeriodStart: new Date('2026-08-01T00:00:00Z'),
+ billingPeriodEnd: new Date('2026-09-01T00:00:00Z'),
+ lastPeriodCost: 31,
+}
+
+describe('getAccountBillingSnapshot', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.events.length = 0
+ })
+
+ it('reuses one resolved subscription for org scope, usage, limits, and credits', async () => {
+ const subscription = {
+ plan: 'team',
+ referenceId: 'org-1',
+ }
+ mocks.getResolvedUserUsageData.mockImplementation(async () => {
+ mocks.events.push('usage-and-subscription')
+ return { usage, subscription, personalCreditBalance: 4 }
+ })
+ mocks.isOrgScopedSubscription.mockReturnValue(true)
+ mocks.getCreditBalanceForEntity.mockImplementation(async () => {
+ mocks.events.push('credits')
+ return 25
+ })
+
+ await expect(getAccountBillingSnapshot('user-1')).resolves.toEqual({
+ plan: 'team',
+ billingScope: 'organization',
+ organizationId: 'org-1',
+ usage: {
+ currentPeriodCost: 18.5,
+ limit: 40,
+ remaining: 21.5,
+ percentUsed: 46.25,
+ isExceeded: false,
+ billingPeriodEnd: new Date('2026-09-01T00:00:00Z'),
+ },
+ credits: { balance: 25, scope: 'organization' },
+ })
+ expect(mocks.getResolvedUserUsageData).toHaveBeenCalledOnce()
+ expect(mocks.getCreditBalanceForEntity).toHaveBeenCalledWith(
+ 'organization',
+ 'org-1',
+ expect.anything()
+ )
+ expect(mocks.events).toEqual(['usage-and-subscription', 'credits'])
+ })
+
+ it('preserves personal scope and clamps negative remaining usage to zero', async () => {
+ mocks.getResolvedUserUsageData.mockResolvedValue({
+ usage: { ...usage, currentUsage: 45, isExceeded: true },
+ subscription: { plan: 'pro', referenceId: 'user-1' },
+ personalCreditBalance: 0,
+ })
+ mocks.isOrgScopedSubscription.mockReturnValue(false)
+ mocks.getCreditBalanceForEntity.mockResolvedValue(0)
+
+ await expect(getAccountBillingSnapshot('user-1')).resolves.toMatchObject({
+ plan: 'pro',
+ billingScope: 'user',
+ organizationId: null,
+ usage: { remaining: 0, isExceeded: true },
+ credits: { balance: 0, scope: 'user' },
+ })
+ expect(mocks.getCreditBalanceForEntity).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/lib/billing/core/account-billing-snapshot.ts b/apps/sim/lib/billing/core/account-billing-snapshot.ts
new file mode 100644
index 0000000000..a7591357ac
--- /dev/null
+++ b/apps/sim/lib/billing/core/account-billing-snapshot.ts
@@ -0,0 +1,58 @@
+import { db } from '@sim/db'
+import { getResolvedUserUsageData } from '@/lib/billing/core/usage'
+import { getCreditBalanceForEntity } from '@/lib/billing/credits/balance'
+import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
+import type { DbClient } from '@/lib/db/types'
+
+export interface AccountBillingSnapshot {
+ plan: string
+ billingScope: 'user' | 'organization'
+ organizationId: string | null
+ usage: {
+ currentPeriodCost: number
+ limit: number
+ remaining: number
+ percentUsed: number
+ isExceeded: boolean
+ billingPeriodEnd: Date | null
+ }
+ credits: {
+ balance: number
+ scope: 'user' | 'organization'
+ }
+}
+
+/** Resolves one coherent subscription, usage, limit, and credit snapshot for an account. */
+export async function getAccountBillingSnapshot(
+ userId: string,
+ executor: DbClient = db
+): Promise {
+ const { usage, subscription, personalCreditBalance } = await getResolvedUserUsageData(
+ userId,
+ executor
+ )
+ const organizationScoped = isOrgScopedSubscription(subscription, userId) && subscription !== null
+ const billingScope = organizationScoped ? 'organization' : 'user'
+ const billingEntityId = organizationScoped ? subscription.referenceId : userId
+ const creditBalance = organizationScoped
+ ? await getCreditBalanceForEntity('organization', billingEntityId, executor)
+ : personalCreditBalance
+
+ return {
+ plan: subscription?.plan || 'free',
+ billingScope,
+ organizationId: organizationScoped ? subscription.referenceId : null,
+ usage: {
+ currentPeriodCost: usage.currentUsage,
+ limit: usage.limit,
+ remaining: Math.max(0, usage.limit - usage.currentUsage),
+ percentUsed: usage.percentUsed,
+ isExceeded: usage.isExceeded,
+ billingPeriodEnd: usage.billingPeriodEnd,
+ },
+ credits: {
+ balance: creditBalance,
+ scope: billingScope,
+ },
+ }
+}
diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts
index d697c9fd7f..a2359d28a2 100644
--- a/apps/sim/lib/billing/core/subscription.ts
+++ b/apps/sim/lib/billing/core/subscription.ts
@@ -713,7 +713,7 @@ export async function hasWorkspaceLiveSyncAccess(workspaceId: string): Promise {
try {
diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts
index bdc3af07bb..d001876869 100644
--- a/apps/sim/lib/billing/core/usage.ts
+++ b/apps/sim/lib/billing/core/usage.ts
@@ -14,7 +14,10 @@ import {
} from '@/components/emails'
import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
import { defaultBillingPeriod } from '@/lib/billing/core/billing-period'
-import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
+import {
+ getHighestPrioritySubscription,
+ type HighestPrioritySubscription,
+} from '@/lib/billing/core/plan'
import {
type ResolvedUsagePeriod,
resolveSubscriptionUsagePeriod,
@@ -197,13 +200,18 @@ export async function ensureUserStatsExists(userId: string): Promise {
.onConflictDoNothing({ target: userStats.userId })
}
-/**
- * Get comprehensive usage data for a user
- */
-export async function getUserUsageData(
+export interface ResolvedUserUsageData {
+ usage: UsageData
+ subscription: HighestPrioritySubscription
+ /** The personal balance from the same user-stats row used to calculate usage. */
+ personalCreditBalance: number
+}
+
+/** Resolves comprehensive usage and the subscription that determined its billing scope. */
+export async function getResolvedUserUsageData(
userId: string,
executor: DbClient = db
-): Promise {
+): Promise {
try {
// Write — always on the primary regardless of executor routing.
await ensureUserStatsExists(userId)
@@ -344,14 +352,18 @@ export async function getUserUsageData(
const isExceeded = effectiveUsage >= limit
return {
- currentUsage: effectiveUsage,
- limit,
- percentUsed,
- isWarning,
- isExceeded,
- billingPeriodStart,
- billingPeriodEnd,
- lastPeriodCost,
+ usage: {
+ currentUsage: effectiveUsage,
+ limit,
+ percentUsed,
+ isWarning,
+ isExceeded,
+ billingPeriodStart,
+ billingPeriodEnd,
+ lastPeriodCost,
+ },
+ subscription,
+ personalCreditBalance: toNumber(toDecimal(stats.creditBalance)),
}
} catch (error) {
logger.error('Failed to get user usage data', { userId, error })
@@ -359,6 +371,14 @@ export async function getUserUsageData(
}
}
+/** Get comprehensive usage data for a user. */
+export async function getUserUsageData(
+ userId: string,
+ executor: DbClient = db
+): Promise {
+ return (await getResolvedUserUsageData(userId, executor)).usage
+}
+
/**
* Get usage limit information for a user
*/
diff --git a/apps/sim/lib/browser-agent/open-in-panel.ts b/apps/sim/lib/browser-agent/open-in-panel.ts
index 651cfcb82d..bc19973067 100644
--- a/apps/sim/lib/browser-agent/open-in-panel.ts
+++ b/apps/sim/lib/browser-agent/open-in-panel.ts
@@ -1,6 +1,6 @@
/**
* "Open this URL in the Sim browser panel" — the desktop-only affordance that
- * routes chat links into the embedded agent browser instead of a new browser
+ * routes chat links into the embedded agent browser instead of a system-browser
* tab.
*
* Rendering components (markdown links, chips) can't reach the chat's
diff --git a/apps/sim/lib/browser-agent/transport.test.ts b/apps/sim/lib/browser-agent/transport.test.ts
index 75701bedfa..b264fc674c 100644
--- a/apps/sim/lib/browser-agent/transport.test.ts
+++ b/apps/sim/lib/browser-agent/transport.test.ts
@@ -153,6 +153,7 @@ import {
fillBrowserCredential,
initBrowserAgentTransport,
loadBrowserFillOptions,
+ loadBrowserSearchSuggestions,
migrateBrowserScope,
onBrowserAddToChat,
onBrowserFillAvailability,
@@ -162,6 +163,7 @@ import {
onBrowserOmniboxFocus,
onBrowserToolbarCommand,
openBrowserTab,
+ openUrlInNewBrowserTab,
reorderBrowserTab,
reportBrowserPanelBounds,
reportBrowserPanelFocused,
@@ -243,6 +245,33 @@ describe('browser panel transport', () => {
expect(setTabsState).toHaveBeenCalledWith(state)
})
+ it('opens chat URLs in a distinct tab before navigating', async () => {
+ const callOrder: string[] = []
+ openTab.mockImplementation(async () => {
+ callOrder.push('open-tab')
+ return {
+ scopeId: 'chat-test',
+ activeTabId: '2',
+ tabs: [],
+ }
+ })
+ panelAction.mockImplementation(() => {
+ callOrder.push('navigate')
+ })
+
+ await openUrlInNewBrowserTab('https://example.com/docs', 'chat-test')
+
+ expect(callOrder).toEqual(['open-tab', 'navigate'])
+ expect(panelAction).toHaveBeenCalledWith(
+ { action: 'navigate', url: 'https://example.com/docs' },
+ 'chat-test'
+ )
+ })
+
+ it('keeps search suggestions local-only on older installed shells', async () => {
+ await expect(loadBrowserSearchSuggestions('sim ai')).resolves.toEqual([])
+ })
+
it('forwards panel bounds directly to the native view', () => {
const initialBounds = { x: 10, y: 20, width: 300, height: 200 }
const updatedBounds = { x: 20, y: 30, width: 320, height: 220 }
diff --git a/apps/sim/lib/browser-agent/transport.ts b/apps/sim/lib/browser-agent/transport.ts
index 51815f9755..46aa80f1e2 100644
--- a/apps/sim/lib/browser-agent/transport.ts
+++ b/apps/sim/lib/browser-agent/transport.ts
@@ -307,6 +307,15 @@ export async function openBrowserTab(
return state
}
+/** Opens a distinct browser tab and navigates it after the shell accepts it. */
+export async function openUrlInNewBrowserTab(
+ url: string,
+ scopeId = currentBrowserScopeId()
+): Promise {
+ await openBrowserTab(scopeId)
+ sendBrowserPanelAction('navigate', { url }, scopeId)
+}
+
/** Pins or unpins a live browser tab. */
export function setBrowserTabPinned(
tabId: string,
@@ -487,6 +496,19 @@ export async function loadBrowserSuggestionSources(): Promise<{
return { sessions: known?.sessions ?? [], credentials, sites }
}
+/**
+ * Requests live Google completions through the desktop shell. Older shells and
+ * disabled/offline providers resolve to an empty list, leaving local sites and
+ * ordinary Enter-to-search behavior intact.
+ */
+export function loadBrowserSearchSuggestions(query: string): Promise {
+ return (
+ bridge()
+ ?.getSearchSuggestions?.(query)
+ .catch(() => []) ?? Promise.resolve([])
+ )
+}
+
/** Subscribes to native browser shortcuts that target the renderer omnibox. */
export function onBrowserOmniboxFocus(
callback: (mode: BrowserOmniboxFocusMode) => void,
diff --git a/apps/sim/lib/charts/option.ts b/apps/sim/lib/charts/option.ts
new file mode 100644
index 0000000000..d862a89291
--- /dev/null
+++ b/apps/sim/lib/charts/option.ts
@@ -0,0 +1,86 @@
+/**
+ * ECharts option assembly for the `.chart` file viewer. Pure: no React, no
+ * echarts import, JSON-in/JSON-out.
+ *
+ * Chrome layout is Sim-owned, content is spec-owned. Models reliably produce
+ * colliding title/legend placements, so the renderer pins the title top-left
+ * and the legend top-right on one chrome row (scrollable when long),
+ * overriding any spec positions — the same split the pptx renderer makes
+ * between slide chrome and slide content.
+ */
+
+export interface ChartRenderInput {
+ title?: string
+ option: Record
+ rows?: Array> | null
+}
+
+export function buildChartRenderOption({
+ title,
+ option: specOption,
+ rows,
+}: ChartRenderInput): Record {
+ const option = structuredClone(specOption)
+ if (rows && rows.length > 0) {
+ // The resolved rows become the FIRST dataset (id "table", datasetIndex 0).
+ // Spec-declared datasets follow it, so filter/sort transform datasets can
+ // derive from the injected rows (transforms default to fromDatasetIndex 0,
+ // or name it explicitly with fromDatasetId: "table").
+ const injected = { id: 'table', source: rows }
+ if (option.dataset === undefined) {
+ option.dataset = injected
+ } else if (Array.isArray(option.dataset)) {
+ option.dataset = [injected, ...option.dataset]
+ } else {
+ option.dataset = [injected, option.dataset]
+ }
+ }
+ if (option.backgroundColor === undefined) {
+ option.backgroundColor = 'transparent'
+ }
+ if (title && option.title === undefined) {
+ option.title = { text: title }
+ }
+ const hasTitle = option.title !== null && typeof option.title === 'object'
+ if (hasTitle) {
+ const titles = Array.isArray(option.title) ? option.title : [option.title]
+ const primary = titles[0]
+ if (primary !== null && typeof primary === 'object') {
+ const t = primary as Record
+ t.left = 0
+ t.top = 0
+ t.right = undefined
+ t.bottom = undefined
+ }
+ option.title = titles[0]
+ }
+ let hasLegend = false
+ if (option.legend !== null && typeof option.legend === 'object') {
+ const legends = Array.isArray(option.legend) ? option.legend : [option.legend]
+ for (const entry of legends) {
+ if (entry === null || typeof entry !== 'object') continue
+ hasLegend = true
+ const l = entry as Record
+ l.top = 2
+ l.right = 0
+ l.left = undefined
+ l.bottom = undefined
+ if (l.type === undefined) l.type = 'scroll'
+ }
+ }
+ // Reserve a chrome row above the plot. Fill only what the spec left unset
+ // inside grid — axis-name insets remain the spec's call.
+ const chromeTop = hasTitle || hasLegend ? 48 : 16
+ if (option.grid === undefined) {
+ option.grid = { top: chromeTop, left: 12, right: 12, bottom: 12, containLabel: true }
+ } else if (
+ option.grid !== null &&
+ typeof option.grid === 'object' &&
+ !Array.isArray(option.grid)
+ ) {
+ const g = option.grid as Record
+ if (g.top === undefined) g.top = chromeTop
+ if (g.containLabel === undefined) g.containLabel = true
+ }
+ return option
+}
diff --git a/apps/sim/lib/charts/spec.test.ts b/apps/sim/lib/charts/spec.test.ts
new file mode 100644
index 0000000000..da150499d4
--- /dev/null
+++ b/apps/sim/lib/charts/spec.test.ts
@@ -0,0 +1,116 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import { parseChartSpec, shapeTableRows } from '@/lib/charts/spec'
+
+const rows = [
+ { month: '2024-01', region: 'NA', revenue: 100, conversion: 4 },
+ { month: '2024-01', region: 'EMEA', revenue: 50, conversion: 2 },
+ { month: '2024-02', region: 'NA', revenue: 200, conversion: 6 },
+ { month: '2024-02', region: 'EMEA', revenue: 80, conversion: 4 },
+]
+
+describe('shapeTableRows', () => {
+ it('passes rows through without groupBy', () => {
+ expect(shapeTableRows(rows, { type: 'table', tableId: 't' })).toBe(rows)
+ })
+
+ it('groups and aggregates, keeping first-seen group order', () => {
+ const shaped = shapeTableRows(rows, {
+ type: 'table',
+ tableId: 't',
+ groupBy: ['month'],
+ aggregate: { revenue: 'sum', conversion: 'avg' },
+ })
+ expect(shaped).toEqual([
+ { month: '2024-01', revenue: 150, conversion: 3 },
+ { month: '2024-02', revenue: 280, conversion: 5 },
+ ])
+ })
+
+ it('pivots a single metric into per-value columns', () => {
+ const shaped = shapeTableRows(rows, {
+ type: 'table',
+ tableId: 't',
+ groupBy: ['month'],
+ aggregate: { revenue: 'sum' },
+ pivot: 'region',
+ })
+ expect(shaped).toEqual([
+ { month: '2024-01', NA: 100, EMEA: 50 },
+ { month: '2024-02', NA: 200, EMEA: 80 },
+ ])
+ })
+
+ it('prefixes pivot columns when several metrics are aggregated', () => {
+ const shaped = shapeTableRows(rows, {
+ type: 'table',
+ tableId: 't',
+ groupBy: ['month'],
+ aggregate: { revenue: 'sum', conversion: 'avg' },
+ pivot: 'region',
+ })
+ expect(shaped[0]).toEqual({
+ month: '2024-01',
+ 'NA revenue': 100,
+ 'NA conversion': 4,
+ 'EMEA revenue': 50,
+ 'EMEA conversion': 2,
+ })
+ })
+
+ it('counts rows and ignores non-numeric values in numeric ops', () => {
+ const noisy = [
+ { g: 'a', v: 1 },
+ { g: 'a', v: 'oops' },
+ { g: 'a', v: 3 },
+ ]
+ expect(
+ shapeTableRows(noisy, {
+ type: 'table',
+ tableId: 't',
+ groupBy: ['g'],
+ aggregate: { v: 'count' },
+ })
+ ).toEqual([{ g: 'a', v: 3 }])
+ expect(
+ shapeTableRows(noisy, {
+ type: 'table',
+ tableId: 't',
+ groupBy: ['g'],
+ aggregate: { v: 'avg' },
+ })
+ ).toEqual([{ g: 'a', v: 2 }])
+ })
+})
+
+describe('parseChartSpec table-shaping validation', () => {
+ it('rejects groupBy without aggregate and bad ops', () => {
+ const base = { schema_version: 1, option: {} }
+ expect(
+ parseChartSpec(
+ JSON.stringify({
+ ...base,
+ source: { type: 'table', tableId: 't', groupBy: ['m'] },
+ })
+ ).error
+ ).toMatch(/aggregate/)
+ expect(
+ parseChartSpec(
+ JSON.stringify({
+ ...base,
+ source: { type: 'table', tableId: 't', groupBy: ['m'], aggregate: { v: 'median' } },
+ })
+ ).error
+ ).toMatch(/ops/)
+ expect(
+ parseChartSpec(
+ JSON.stringify({
+ ...base,
+ source: { type: 'table', tableId: 't', pivot: 'region' },
+ })
+ ).error
+ ).toMatch(/pivot/)
+ })
+})
diff --git a/apps/sim/lib/charts/spec.ts b/apps/sim/lib/charts/spec.ts
new file mode 100644
index 0000000000..c406cab607
--- /dev/null
+++ b/apps/sim/lib/charts/spec.ts
@@ -0,0 +1,203 @@
+/**
+ * The `.chart` document contract for the file viewer: parsing/validation and
+ * the table-source shaping (group → aggregate → pivot). Pure JSON-in/JSON-out
+ * — no React, no echarts.
+ */
+
+import { getErrorMessage } from '@sim/utils/errors'
+import { getColumnId } from '@/lib/table/column-keys'
+import type { ColumnDefinition } from '@/lib/table/types'
+
+/** Hard cap on rows a table-backed chart pulls per read; the spec's `limit` clamps under it. */
+export const CHART_ROWS_MAX = 5000
+export const CHART_ROWS_DEFAULT = 1000
+
+export type ChartAggregateOp = 'sum' | 'avg' | 'min' | 'max' | 'count'
+
+export const CHART_AGGREGATE_OPS = new Set(['sum', 'avg', 'min', 'max', 'count'])
+
+export interface ChartTableSource {
+ type: 'table'
+ tableId: string
+ filter?: unknown
+ sort?: unknown
+ limit?: number
+ /** Group rows by these columns; requires `aggregate`. */
+ groupBy?: string[]
+ /** Metric column → op, computed per group. */
+ aggregate?: Record
+ /** Fan the aggregated metric(s) out into one column per distinct value of this column. */
+ pivot?: string
+}
+
+export interface ChartStaticSource {
+ type: 'static'
+ rows?: Array>
+}
+
+/**
+ * A `.chart` file (`text/x-sim-chart`): a declarative ECharts document. The
+ * `option` is a plain ECharts option object; `source` optionally supplies the
+ * data — inline rows, or a live read of a Sim table injected as
+ * `option.dataset.source` so the chart stays current with the table.
+ */
+export interface ChartSpec {
+ schema_version: number
+ title?: string
+ source?: ChartStaticSource | ChartTableSource
+ option: Record
+}
+
+export function parseChartSpec(content: string): { spec?: ChartSpec; error?: string } {
+ let raw: unknown
+ try {
+ raw = JSON.parse(content)
+ } catch (e) {
+ return { error: getErrorMessage(e, 'not valid JSON') }
+ }
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
+ return { error: 'chart document must be a JSON object' }
+ }
+ const doc = raw as Record
+ if (doc.schema_version !== 1) {
+ return { error: 'chart document must declare "schema_version": 1' }
+ }
+ if (doc.option === null || typeof doc.option !== 'object' || Array.isArray(doc.option)) {
+ return { error: 'chart document must declare an "option" object (an ECharts option)' }
+ }
+ const source = doc.source as ChartSpec['source']
+ if (source !== undefined) {
+ if (source === null || typeof source !== 'object') {
+ return { error: '"source" must be an object' }
+ }
+ if (source.type === 'table') {
+ if (typeof source.tableId !== 'string' || source.tableId === '') {
+ return { error: 'a table source must declare "tableId"' }
+ }
+ if (source.groupBy !== undefined) {
+ if (!Array.isArray(source.groupBy) || source.groupBy.some((c) => typeof c !== 'string')) {
+ return { error: '"groupBy" must be an array of column names' }
+ }
+ if (source.aggregate === null || typeof source.aggregate !== 'object') {
+ return { error: '"groupBy" requires an "aggregate" object ({column: op})' }
+ }
+ const ops = Object.values(source.aggregate)
+ if (ops.length === 0 || ops.some((op) => !CHART_AGGREGATE_OPS.has(String(op)))) {
+ return { error: '"aggregate" ops must be sum, avg, min, max, or count' }
+ }
+ }
+ if (source.pivot !== undefined) {
+ if (typeof source.pivot !== 'string') return { error: '"pivot" must be a column name' }
+ if (!source.groupBy) return { error: '"pivot" requires "groupBy" and "aggregate"' }
+ }
+ } else if (source.type === 'static') {
+ if (source.rows !== undefined && !Array.isArray(source.rows)) {
+ return { error: 'a static source\'s "rows" must be an array' }
+ }
+ } else {
+ return { error: '"source.type" must be "static" or "table"' }
+ }
+ }
+ // Built explicitly from the validated fields — no blanket cast, and no
+ // unvalidated extra keys riding along on the parsed spec.
+ return {
+ spec: {
+ schema_version: 1,
+ title: typeof doc.title === 'string' ? doc.title : undefined,
+ source,
+ option: doc.option as Record,
+ },
+ }
+}
+
+/**
+ * Table rows arrive keyed by column ID (an opaque uuid); chart specs — like
+ * the mothership table tool and the public API — speak column NAMES. Remap so
+ * `encode`/dimension references in the option match what the author sees in
+ * the table UI.
+ */
+export function mapRowsToColumnNames(
+ rows: Array<{ data: Record }>,
+ columns: Array>
+): Array> {
+ const nameByStorageKey = new Map()
+ for (const col of columns) nameByStorageKey.set(getColumnId(col), col.name)
+ return rows.map((row) => {
+ const out: Record = {}
+ for (const [key, value] of Object.entries(row.data)) {
+ out[nameByStorageKey.get(key) ?? key] = value
+ }
+ return out
+ })
+}
+
+function aggregateValues(
+ rows: Array>,
+ column: string,
+ op: ChartAggregateOp
+): number {
+ if (op === 'count') return rows.length
+ const values = rows.map((r) => Number(r[column])).filter((n) => Number.isFinite(n))
+ if (values.length === 0) return 0
+ switch (op) {
+ case 'sum':
+ return values.reduce((a, b) => a + b, 0)
+ case 'avg':
+ return values.reduce((a, b) => a + b, 0) / values.length
+ case 'min':
+ return Math.min(...values)
+ case 'max':
+ return Math.max(...values)
+ }
+}
+
+/**
+ * Client-side shaping for table sources: group → aggregate → optionally pivot
+ * one column's distinct values into per-value columns. Groups keep first-seen
+ * order, so the source's `sort` decides the category order. This is the whole
+ * "query engine" — deliberately tiny; anything fancier belongs in a static
+ * source with precomputed rows.
+ */
+export function shapeTableRows(
+ rows: Array>,
+ source: ChartTableSource
+): Array> {
+ const { groupBy, aggregate, pivot } = source
+ if (!groupBy || groupBy.length === 0 || !aggregate) return rows
+
+ const groups = new Map>>()
+ for (const row of rows) {
+ const key = groupBy.map((c) => String(row[c] ?? '')).join('\u0000')
+ const bucket = groups.get(key)
+ if (bucket) bucket.push(row)
+ else groups.set(key, [row])
+ }
+
+ const metrics = Object.entries(aggregate)
+ const out: Array> = []
+ for (const bucket of groups.values()) {
+ const shaped: Record = {}
+ for (const c of groupBy) shaped[c] = bucket[0][c]
+ if (pivot) {
+ const byValue = new Map>>()
+ for (const row of bucket) {
+ const value = String(row[pivot] ?? '')
+ const slice = byValue.get(value)
+ if (slice) slice.push(row)
+ else byValue.set(value, [row])
+ }
+ for (const [value, slice] of byValue) {
+ for (const [column, op] of metrics) {
+ const name = metrics.length === 1 ? value : `${value} ${column}`
+ shaped[name] = aggregateValues(slice, column, op)
+ }
+ }
+ } else {
+ for (const [column, op] of metrics) {
+ shaped[column] = aggregateValues(bucket, column, op)
+ }
+ }
+ out.push(shaped)
+ }
+ return out
+}
diff --git a/apps/sim/lib/copilot/application/error.ts b/apps/sim/lib/copilot/application/error.ts
index 966a3233b7..4f5c862616 100644
--- a/apps/sim/lib/copilot/application/error.ts
+++ b/apps/sim/lib/copilot/application/error.ts
@@ -1,13 +1,46 @@
+import { trace } from '@opentelemetry/api'
+import { toError } from '@sim/utils/errors'
import { asOrchestrationError } from '@/lib/core/orchestration/types'
export const COPILOT_APPLICATION_SYSTEM_ERROR_MESSAGE =
'The operation failed due to a system error. Please retry.'
-/** Projects only caller-actionable application failures into Copilot-visible content. */
+/**
+ * Projects only caller-actionable application failures into Copilot-visible
+ * content. Whenever the real cause is swallowed by the generic fallback, it is
+ * recorded on the active span first — otherwise these failures are
+ * undiagnosable from telemetry (the cause otherwise lives only in stdout logs
+ * that do not ship anywhere queryable).
+ */
export function messageForCopilotApplicationError(
error: unknown,
fallback = COPILOT_APPLICATION_SYSTEM_ERROR_MESSAGE
): string {
const classified = asOrchestrationError(error)
- return classified && classified.code !== 'internal' ? classified.message : fallback
+ if (classified && classified.code !== 'internal') {
+ return classified.message
+ }
+ trace.getActiveSpan()?.recordException(flattenErrorChain(error))
+ return fallback
+}
+
+/**
+ * Wrapper errors (Drizzle's "Failed query: ") bury the actionable cause —
+ * the Postgres constraint/violation — in `cause`. Join the chain so the span
+ * exception carries the part an investigator actually needs.
+ */
+function flattenErrorChain(error: unknown): Error {
+ const primary = toError(error)
+ const parts = [primary.message]
+ let cursor: unknown = primary.cause
+ let depth = 0
+ while (cursor && depth < 4) {
+ parts.push(toError(cursor).message)
+ cursor = toError(cursor).cause
+ depth += 1
+ }
+ if (parts.length === 1) return primary
+ const flattened = new Error(parts.join(' ← '))
+ flattened.stack = primary.stack
+ return flattened
}
diff --git a/apps/sim/lib/copilot/application/execute-platform-context-use-case.ts b/apps/sim/lib/copilot/application/execute-platform-context-use-case.ts
new file mode 100644
index 0000000000..b891ea294c
--- /dev/null
+++ b/apps/sim/lib/copilot/application/execute-platform-context-use-case.ts
@@ -0,0 +1,40 @@
+import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter'
+import { messageForCopilotApplicationError } from '@/lib/copilot/application/error'
+import {
+ COPILOT_APPLICATION_DELEGATION_TTL_MS,
+ type CopilotExecutionContext,
+ InteractiveCopilotExecutionRequiredError,
+ requireInteractiveCopilotExecutionContext,
+} from '@/lib/copilot/auth/application-delegation'
+import type { OperationUseCase } from '@/lib/core/application'
+import { platformContextDelegationPolicy } from '@/lib/platform-context/application/authorization'
+import {
+ type PlatformContextOperation,
+ platformContextOperations,
+} from '@/lib/platform-context/application/operations'
+
+const executePlatformContextUseCase = createCopilotApplicationAdapter({
+ domain: 'platform context',
+ delegation: {
+ audience: platformContextDelegationPolicy.audience,
+ ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS,
+ createDelegationId: (context) => `copilot-tool:${context.toolCallId}`,
+ },
+ operations: platformContextOperations,
+})
+
+/** Enters a live platform-context operation only from a trusted interactive Copilot lifecycle. */
+export function executeCopilotPlatformContextUseCase(
+ context: CopilotExecutionContext | undefined,
+ useCase: OperationUseCase,
+ input: I
+): Promise {
+ const trustedContext = requireInteractiveCopilotExecutionContext(context)
+ return executePlatformContextUseCase(trustedContext, useCase, input)
+}
+
+/** Projects only actionable authorization failures into live platform-context tool output. */
+export function messageForCopilotPlatformContextError(error: unknown): string {
+ if (error instanceof InteractiveCopilotExecutionRequiredError) return error.message
+ return messageForCopilotApplicationError(error)
+}
diff --git a/apps/sim/lib/copilot/application/table-commands.test.ts b/apps/sim/lib/copilot/application/table-commands.test.ts
index 900f8b76df..f1d734dec1 100644
--- a/apps/sim/lib/copilot/application/table-commands.test.ts
+++ b/apps/sim/lib/copilot/application/table-commands.test.ts
@@ -12,7 +12,7 @@ const mocks = vi.hoisted(() => ({
createFromFile: { operation: { id: 'tables.imports.create_from_workspace_file' } },
createWorkflowGroup: { operation: { id: 'tables.groups.create' } },
deleteTables: { operation: { id: 'tables.delete' } },
- importFile: { operation: { id: 'tables.imports.workspace_file' } },
+ importFile: { operation: { id: 'tables.imports.prepare_file_edit' } },
replaceProjectedRows: { operation: { id: 'tables.rows.replace' } },
updateWorkflowGroup: { operation: { id: 'tables.groups.update' } },
},
diff --git a/apps/sim/lib/copilot/async-runs/repository.test.ts b/apps/sim/lib/copilot/async-runs/repository.test.ts
index fcd9c01a4e..81bdd06198 100644
--- a/apps/sim/lib/copilot/async-runs/repository.test.ts
+++ b/apps/sim/lib/copilot/async-runs/repository.test.ts
@@ -11,6 +11,7 @@ import {
completeAsyncToolCall,
detachAsyncToolCall,
getClaimedWorkflowExecutionId,
+ markAsyncToolRunning,
recordToolPermissionDecision,
releaseWorkflowToolExecutionClaim,
replaceTerminalAsyncToolCallResult,
@@ -162,6 +163,29 @@ describe('async tool repository single-row semantics', () => {
await expect(claimWorkflowToolExecution('workflow-tool', 'execution-2')).resolves.toBeNull()
})
+ it('overwrites a workflow execution claim once the sim path starts running it', async () => {
+ // The server-side fallback claims `workflow:` and then immediately runs
+ // the tool, whose executor re-marks the row as running under 'sim-stream'.
+ // The claim value is therefore NOT durable identity — only its
+ // `claimedBy IS NULL` precondition is load-bearing, since that is what keeps
+ // a late browser locked out. Pinning this so nobody builds on reading it back.
+ dbChainMockFns.returning.mockResolvedValueOnce([
+ {
+ toolCallId: 'workflow-tool',
+ status: 'running',
+ claimedBy: 'sim-stream',
+ },
+ ])
+
+ const result = await markAsyncToolRunning('workflow-tool', 'sim-stream')
+
+ expect(result).toMatchObject({ claimedBy: 'sim-stream' })
+ expect(dbChainMockFns.set).toHaveBeenCalledWith(
+ expect.objectContaining({ claimedBy: 'sim-stream' })
+ )
+ expect(getClaimedWorkflowExecutionId('sim-stream')).toBeUndefined()
+ })
+
it('releases a matching pre-start workflow claim without changing its lifecycle status', async () => {
dbChainMockFns.returning.mockResolvedValueOnce([
{
@@ -258,7 +282,7 @@ describe('async tool repository single-row semantics', () => {
const existingRow = {
runId: 'run-1',
toolCallId: 'tool-1',
- toolName: 'function_execute',
+ toolName: 'run_function',
args: { language: 'javascript', code: 'return {{FIRST_SECRET}}' },
status,
}
@@ -267,7 +291,7 @@ describe('async tool repository single-row semantics', () => {
const result = await upsertAsyncToolCall({
runId: 'run-1',
toolCallId: 'tool-1',
- toolName: 'function_execute',
+ toolName: 'run_function',
args: { language: 'javascript', code: 'return {{SECOND_SECRET}}' },
status: 'pending',
})
diff --git a/apps/sim/lib/copilot/auth/application-delegation.test.ts b/apps/sim/lib/copilot/auth/application-delegation.test.ts
index 1e0bdc5037..655f685fc6 100644
--- a/apps/sim/lib/copilot/auth/application-delegation.test.ts
+++ b/apps/sim/lib/copilot/auth/application-delegation.test.ts
@@ -4,6 +4,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
createCopilotApplicationPrincipal,
+ requireInteractiveCopilotExecutionContext,
requireTrustedCopilotExecutionContext,
} from '@/lib/copilot/auth/application-delegation'
@@ -61,4 +62,25 @@ describe('Copilot application delegation', () => {
},
})
})
+
+ it.each([undefined, 'headless' as const])(
+ 'rejects non-interactive live platform context (%s)',
+ (copilotInteractionMode) => {
+ expect(() =>
+ requireInteractiveCopilotExecutionContext({
+ ...trustedContext,
+ copilotInteractionMode,
+ })
+ ).toThrow('only in an interactive Copilot session')
+ }
+ )
+
+ it('accepts a server-classified interactive lifecycle', () => {
+ expect(
+ requireInteractiveCopilotExecutionContext({
+ ...trustedContext,
+ copilotInteractionMode: 'interactive',
+ })
+ ).toMatchObject({ copilotInteractionMode: 'interactive' })
+ })
})
diff --git a/apps/sim/lib/copilot/auth/application-delegation.ts b/apps/sim/lib/copilot/auth/application-delegation.ts
index d4e91c30bb..4a779e0a02 100644
--- a/apps/sim/lib/copilot/auth/application-delegation.ts
+++ b/apps/sim/lib/copilot/auth/application-delegation.ts
@@ -11,6 +11,7 @@ export interface CopilotExecutionContext {
executionId?: string
toolCallId?: string
copilotToolExecution?: boolean
+ copilotInteractionMode?: 'interactive' | 'headless'
}
export interface TrustedCopilotExecutionContext extends CopilotExecutionContext {
@@ -20,6 +21,17 @@ export interface TrustedCopilotExecutionContext extends CopilotExecutionContext
copilotToolExecution: true
}
+export interface TrustedInteractiveCopilotExecutionContext extends TrustedCopilotExecutionContext {
+ copilotInteractionMode: 'interactive'
+}
+
+export class InteractiveCopilotExecutionRequiredError extends Error {
+ constructor() {
+ super('Live platform context is available only in an interactive Copilot session.')
+ this.name = 'InteractiveCopilotExecutionRequiredError'
+ }
+}
+
export type CopilotResourceScope = Pick<
NonNullable,
'fileId' | 'tableId'
@@ -76,9 +88,23 @@ export function requireTrustedCopilotExecutionContext(
...(context.executionId ? { executionId: context.executionId } : {}),
toolCallId: context.toolCallId,
copilotToolExecution: true,
+ ...(context.copilotInteractionMode
+ ? { copilotInteractionMode: context.copilotInteractionMode }
+ : {}),
})
}
+/** Restricts sensitive live platform reads to a server-classified interactive lifecycle. */
+export function requireInteractiveCopilotExecutionContext(
+ context: CopilotExecutionContext | undefined
+): TrustedInteractiveCopilotExecutionContext {
+ const trustedContext = requireTrustedCopilotExecutionContext(context)
+ if (trustedContext.copilotInteractionMode !== 'interactive') {
+ throw new InteractiveCopilotExecutionRequiredError()
+ }
+ return trustedContext as TrustedInteractiveCopilotExecutionContext
+}
+
/** Creates a bounded Copilot principal from an explicitly trusted server lifecycle. */
export function createTrustedCopilotPrincipal(
input: CreateTrustedCopilotPrincipalInput,
diff --git a/apps/sim/lib/copilot/chat/desktop-capabilities.ts b/apps/sim/lib/copilot/chat/desktop-capabilities.ts
new file mode 100644
index 0000000000..b8fb040f92
--- /dev/null
+++ b/apps/sim/lib/copilot/chat/desktop-capabilities.ts
@@ -0,0 +1,3 @@
+/** Wire limits shared by the desktop capability snapshot and its request schema. */
+export const DESKTOP_TERMINAL_HINT_ID_MAX_LENGTH = 64
+export const DESKTOP_TERMINAL_HINT_TEXT_MAX_LENGTH = 1024
diff --git a/apps/sim/lib/copilot/chat/display-message.ts b/apps/sim/lib/copilot/chat/display-message.ts
index 24df088f59..28a348a583 100644
--- a/apps/sim/lib/copilot/chat/display-message.ts
+++ b/apps/sim/lib/copilot/chat/display-message.ts
@@ -92,7 +92,11 @@ function toDisplayBlockBody(block: PersistedContentBlock): ContentBlock | undefi
if (block.lifecycle === MothershipStreamV1SpanLifecycleEvent.end) {
return { type: ContentBlockType.subagent_end }
}
- return { type: ContentBlockType.subagent, content: block.content }
+ return {
+ type: ContentBlockType.subagent,
+ content: block.content,
+ ...(block.name ? { subagentName: block.name } : {}),
+ }
case MothershipStreamV1EventType.complete:
if (block.status === MothershipStreamV1CompletionStatus.cancelled) {
return { type: ContentBlockType.stopped }
@@ -138,17 +142,17 @@ function toDisplayContexts(
}))
}
-const WORKSPACE_FILE_TOOL = 'workspace_file'
-const EDIT_CONTENT_TOOL = 'edit_content'
+const WORKSPACE_FILE_TOOL = 'prepare_file_edit'
+const EDIT_CONTENT_TOOL = 'apply_file_edit'
const MAIN_SPAN = 'main'
/**
- * Collapses an `edit_content` write into the most-recent `workspace_file` row in
+ * Collapses an `apply_file_edit` write into the most-recent `prepare_file_edit` row in
* the same subagent span, mirroring the live turn-model fold. The live view
* folds these in `reduceEvent`, but the persisted transcript stores them as two
* separate tool blocks; without this a reloaded chat splits the file write into
- * "workspace_file" + "edit_content" rows (and a refresh mid-write leaves the
- * second row spinning). The reopened row inherits the edit_content's final
+ * "prepare_file_edit" + "apply_file_edit" rows (and a refresh mid-write leaves the
+ * second row spinning). The reopened row inherits the apply_file_edit's final
* status/result, exactly as the live single "writing" row resolves. Every other
* block is passed through untouched, so this only affects file writes.
*/
diff --git a/apps/sim/lib/copilot/chat/effective-transcript.test.ts b/apps/sim/lib/copilot/chat/effective-transcript.test.ts
index 10c74da054..c525a2cc81 100644
--- a/apps/sim/lib/copilot/chat/effective-transcript.test.ts
+++ b/apps/sim/lib/copilot/chat/effective-transcript.test.ts
@@ -242,7 +242,7 @@ describe('buildEffectiveChatTranscript', () => {
payload: {
phase: 'result',
toolCallId: 'tool-1',
- toolName: 'workspace_file',
+ toolName: 'prepare_file_edit',
executor: 'go',
mode: 'sync',
success: false,
@@ -262,7 +262,7 @@ describe('buildEffectiveChatTranscript', () => {
type: MothershipStreamV1EventType.tool,
toolCall: expect.objectContaining({
id: 'tool-1',
- name: 'workspace_file',
+ name: 'prepare_file_edit',
state: MothershipStreamV1CompletionStatus.cancelled,
}),
}),
@@ -473,3 +473,44 @@ describe('tool ownership is call-frame authoritative', () => {
expect(own.parentToolCallId).toBe('dispatch-1')
})
})
+
+describe('user aborts are not rendered as errors', () => {
+ function transcriptWithErrorEvent(code: string) {
+ return buildEffectiveChatTranscript({
+ messages: [buildUserMessage('stream-1', 'Hello')],
+ activeStreamId: 'stream-1',
+ streamSnapshot: {
+ events: [
+ toBatchEvent(1, {
+ v: 1,
+ seq: 1,
+ ts: '2026-04-15T12:00:01.000Z',
+ type: MothershipStreamV1EventType.error,
+ stream: { streamId: 'stream-1' },
+ payload: { code, message: 'Request aborted by user' },
+ }),
+ ],
+ previewSessions: [],
+ status: 'active',
+ },
+ })
+ }
+
+ function renderedContent(messages: ReturnType) {
+ return messages
+ .filter((message) => message.role === 'assistant')
+ .map((message) => message.content ?? '')
+ .join('')
+ }
+
+ it.each(['async_resume_aborted', 'stream_cancelled', 'cancelled'])(
+ 'suppresses the inline error tag for %s',
+ (code) => {
+ expect(renderedContent(transcriptWithErrorEvent(code))).not.toContain('mothership-error')
+ }
+ )
+
+ it('still renders a genuine failure', () => {
+ expect(renderedContent(transcriptWithErrorEvent('api_error'))).toContain('mothership-error')
+ })
+})
diff --git a/apps/sim/lib/copilot/chat/effective-transcript.ts b/apps/sim/lib/copilot/chat/effective-transcript.ts
index 6594e0f8c7..97527da2c7 100644
--- a/apps/sim/lib/copilot/chat/effective-transcript.ts
+++ b/apps/sim/lib/copilot/chat/effective-transcript.ts
@@ -60,6 +60,23 @@ function isTerminalStreamStatus(status: string | null | undefined): boolean {
)
}
+/**
+ * Error codes that describe the USER stopping the turn, not a failure.
+ *
+ * A Stop reaches the client as an `error` event because the server's typed
+ * error channel is the only way a terminating stream leg reports its reason —
+ * `async_resume_aborted` in particular is emitted when Stop lands while an
+ * async tool resume is still starting up. The server already classifies these
+ * as aborts (recording an abort rather than an error, and answering 499 rather
+ * than 5xx); rendering them inline made the user's own Stop look like a
+ * failure, so the transcript honors that classification too.
+ */
+const USER_ABORT_ERROR_CODES = new Set(['async_resume_aborted', 'stream_cancelled', 'cancelled'])
+
+function isUserAbortError(payload: MothershipStreamV1ErrorPayload): boolean {
+ return typeof payload.code === 'string' && USER_ABORT_ERROR_CODES.has(payload.code)
+}
+
function buildInlineErrorTag(payload: MothershipStreamV1ErrorPayload): string {
const message =
(typeof payload.displayMessage === 'string' ? payload.displayMessage : undefined) ||
@@ -468,6 +485,11 @@ function buildLiveAssistantMessage(params: {
continue
}
case MothershipStreamV1EventType.error: {
+ // A Stop is already visible: the stream ends and the composer returns.
+ // Adding a line for it just reports the user's own action back as an error.
+ if (isUserAbortError(parsed.payload)) {
+ continue
+ }
const tag = buildInlineErrorTag(parsed.payload)
if (runningText.includes(tag)) {
continue
diff --git a/apps/sim/lib/copilot/chat/payload.test.ts b/apps/sim/lib/copilot/chat/payload.test.ts
index 2f061cf859..4ec333268b 100644
--- a/apps/sim/lib/copilot/chat/payload.test.ts
+++ b/apps/sim/lib/copilot/chat/payload.test.ts
@@ -368,7 +368,7 @@ describe('buildCopilotRequestPayload', () => {
content: [
'File "payroll.xlsx" (application/octet-stream, 1 bytes) uploaded.',
'Read with: read("uploads/payroll.xlsx")',
- 'To save permanently: materialize_file(fileName: "payroll.xlsx")',
+ 'To save permanently: save_upload(fileName: "payroll.xlsx")',
].join('\n'),
},
])
@@ -410,7 +410,7 @@ describe('buildCopilotRequestPayload', () => {
content: [
'File "photo.png" (image/png, 10 bytes) uploaded.',
'Read with: read("uploads/photo.png")',
- 'To save permanently: materialize_file(fileName: "photo.png")',
+ 'To save permanently: save_upload(fileName: "photo.png")',
].join('\n'),
},
])
diff --git a/apps/sim/lib/copilot/chat/payload.ts b/apps/sim/lib/copilot/chat/payload.ts
index ef1ea48d15..5f185b683f 100644
--- a/apps/sim/lib/copilot/chat/payload.ts
+++ b/apps/sim/lib/copilot/chat/payload.ts
@@ -362,7 +362,7 @@ export async function buildCopilotRequestPayload(
userMessageId
)
// Encode the read path per the percent-encoded VFS convention (matches
- // files/ and the uploads glob output). The materialize_file `fileName`
+ // files/ and the uploads glob output). The save_upload `fileName`
// arg stays the raw display name — the upload resolver accepts both.
let encodedUploadName = displayName
try {
@@ -382,11 +382,11 @@ export async function buildCopilotRequestPayload(
lines = [
`File "${displayName}" (${mediaType}, ${f.size} bytes) uploaded.`,
`Read with: read("uploads/${encodedUploadName}")`,
- `To save permanently: materialize_file(fileName: "${displayName}")`,
+ `To save permanently: save_upload(fileName: "${displayName}")`,
]
if (displayName.endsWith('.json')) {
lines.push(
- `To import as a workflow: materialize_file(fileName: "${displayName}", operation: "import")`
+ `To import as a workflow: save_upload(fileName: "${displayName}", operation: "import")`
)
}
}
diff --git a/apps/sim/lib/copilot/chat/persisted-message.ts b/apps/sim/lib/copilot/chat/persisted-message.ts
index 54b66e90c9..fb8a30ba5b 100644
--- a/apps/sim/lib/copilot/chat/persisted-message.ts
+++ b/apps/sim/lib/copilot/chat/persisted-message.ts
@@ -15,12 +15,12 @@ import {
MothershipStreamV1ToolOutcome,
MothershipStreamV1ToolPhase,
} from '@/lib/copilot/generated/mothership-stream-v1'
-import { BrowserRequestTakeover } from '@/lib/copilot/generated/tool-catalog-v1'
import type {
ContentBlock,
LocalToolCallStatus,
OrchestratorResult,
} from '@/lib/copilot/request/types'
+import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools'
import type { BrowserTextSelection, TerminalTextSelection } from '@/stores/panel/types'
export type PersistedToolState = LocalToolCallStatus | MothershipStreamV1ToolOutcome | 'interrupted'
@@ -53,6 +53,8 @@ export interface PersistedContentBlock {
lifecycle?: MothershipStreamV1SpanLifecycleEvent
status?: MothershipStreamV1CompletionStatus
content?: string
+ /** Orchestrator-chosen display name on a subagent start block. */
+ name?: string
toolCall?: PersistedToolCall
timestamp?: number
endedAt?: number
@@ -150,7 +152,7 @@ export function stripToolResultOutput(message: PersistedMessage): PersistedMessa
if (!toolCall || !result || typeof result !== 'object' || !('output' in result)) return block
const output = result.output
const userInstruction =
- toolCall.name === BrowserRequestTakeover.id && isPlainRecord(output)
+ toolCall.name === RETIRED_BROWSER_REQUEST_TAKEOVER_ID && isPlainRecord(output)
? output.userInstruction
: undefined
const normalizedInstruction = typeof userInstruction === 'string' ? userInstruction.trim() : ''
@@ -243,6 +245,7 @@ function mapContentBlockBody(block: ContentBlock): PersistedContentBlock {
kind: MothershipStreamV1SpanPayloadKind.subagent,
lifecycle: MothershipStreamV1SpanLifecycleEvent.start,
content: block.content,
+ ...(block.subagentName ? { name: block.subagentName } : {}),
}
case 'subagent_text':
return {
@@ -432,6 +435,9 @@ interface RawBlock {
type: string
lane?: string
agent?: string
+ /** Orchestrator-chosen subagent display name (legacy blocks store it as `subagentName`). */
+ name?: string
+ subagentName?: string
content?: string
/** Go persists text blocks with key "text" instead of "content" */
text?: string
@@ -499,6 +505,7 @@ function normalizeCanonicalBlock(block: RawBlock): PersistedContentBlock {
result.lane = block.lane
}
if (block.agent) result.agent = block.agent
+ if (block.name) result.name = block.name
const blockContent = block.content ?? block.text
if (blockContent !== undefined) result.content = blockContent
if (block.channel) result.channel = block.channel as MothershipStreamV1TextChannel
@@ -580,6 +587,7 @@ function normalizeLegacyBlock(block: RawBlock): PersistedContentBlock {
kind: MothershipStreamV1SpanPayloadKind.subagent,
lifecycle: MothershipStreamV1SpanLifecycleEvent.start,
content: block.content,
+ ...(block.subagentName ? { name: block.subagentName } : {}),
}
}
diff --git a/apps/sim/lib/copilot/chat/post.test.ts b/apps/sim/lib/copilot/chat/post.test.ts
index 4f8efd5202..f7f71a2473 100644
--- a/apps/sim/lib/copilot/chat/post.test.ts
+++ b/apps/sim/lib/copilot/chat/post.test.ts
@@ -384,7 +384,8 @@ describe('handleUnifiedChatPost', () => {
'user-1',
'Hello',
'ws-1',
- expect.anything()
+ expect.anything(),
+ expect.any(ResolvedSecretTraceRegistry)
)
})
@@ -448,7 +449,8 @@ describe('handleUnifiedChatPost', () => {
'user-1',
'Explain these selections',
'ws-1',
- 'chat-1'
+ 'chat-1',
+ expect.any(ResolvedSecretTraceRegistry)
)
})
diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts
index 0bde82a6c4..90b4b3b283 100644
--- a/apps/sim/lib/copilot/chat/post.ts
+++ b/apps/sim/lib/copilot/chat/post.ts
@@ -10,6 +10,10 @@ import { z } from 'zod'
import { isZodError, validationErrorResponse } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution'
+import {
+ DESKTOP_TERMINAL_HINT_ID_MAX_LENGTH,
+ DESKTOP_TERMINAL_HINT_TEXT_MAX_LENGTH,
+} from '@/lib/copilot/chat/desktop-capabilities'
import { type ChatLoadResult, resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle'
import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store'
import { buildCopilotRequestPayload } from '@/lib/copilot/chat/payload'
@@ -289,9 +293,9 @@ const ChatMessageSchema = z.object({
terminals: z
.array(
z.object({
- id: z.string().max(64),
- cwd: z.string().max(1024).optional(),
- running: z.string().max(1024).optional(),
+ id: z.string().max(DESKTOP_TERMINAL_HINT_ID_MAX_LENGTH),
+ cwd: z.string().max(DESKTOP_TERMINAL_HINT_TEXT_MAX_LENGTH).optional(),
+ running: z.string().max(DESKTOP_TERMINAL_HINT_TEXT_MAX_LENGTH).optional(),
interactive: z.boolean().optional(),
active: z.boolean().optional(),
})
@@ -461,9 +465,19 @@ async function resolveAgentContexts(params: {
message: string
workspaceId?: string
chatId?: string
+ resolvedSecretTraceRegistry?: ExecutionContext['resolvedSecretTraceRegistry']
requestId: string
}): Promise> {
- const { contexts, resourceAttachments, userId, message, workspaceId, chatId, requestId } = params
+ const {
+ contexts,
+ resourceAttachments,
+ userId,
+ message,
+ workspaceId,
+ chatId,
+ resolvedSecretTraceRegistry,
+ requestId,
+ } = params
let agentContexts: Array<{ type: string; content: string; tag?: string; path?: string }> = []
@@ -474,7 +488,8 @@ async function resolveAgentContexts(params: {
userId,
message,
workspaceId,
- chatId
+ chatId,
+ resolvedSecretTraceRegistry
)
} catch (error) {
logger.error(`[${requestId}] Failed to process contexts`, error)
@@ -1267,7 +1282,7 @@ export async function handleUnifiedChatPost(req: NextRequest) {
}),
activeOtelRoot.context
)
- const agentContextsPromise = executionContextPromise.then(() => {
+ const agentContextsPromise = executionContextPromise.then((executionContext) => {
return withCopilotSpan(
TraceSpan.CopilotChatResolveAgentContexts,
{
@@ -1282,6 +1297,7 @@ export async function handleUnifiedChatPost(req: NextRequest) {
message: body.message,
workspaceId,
chatId: actualChatId,
+ resolvedSecretTraceRegistry: executionContext.resolvedSecretTraceRegistry,
requestId,
}),
activeOtelRoot.context
diff --git a/apps/sim/lib/copilot/chat/process-contents.test.ts b/apps/sim/lib/copilot/chat/process-contents.test.ts
index 6570628e3b..3e29c8b404 100644
--- a/apps/sim/lib/copilot/chat/process-contents.test.ts
+++ b/apps/sim/lib/copilot/chat/process-contents.test.ts
@@ -10,6 +10,7 @@ import {
MAX_TABLE_SELECTION_ROWS,
} from '@/lib/copilot/chat/selection-context'
import { DelegatedWorkspaceAuthorizationError } from '@/lib/core/application'
+import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
import type { ChatContext } from '@/stores/panel'
const {
@@ -25,6 +26,7 @@ const {
readKnowledgeBase,
getBlockVisibilityForCopilot,
isIntegrationDeploymentAvailable,
+ searchDocsExecute,
} = vi.hoisted(() => ({
discoverServerTools: vi.fn(),
getBlock: vi.fn(),
@@ -38,6 +40,7 @@ const {
readKnowledgeBase: vi.fn(),
getBlockVisibilityForCopilot: vi.fn(async () => null),
isIntegrationDeploymentAvailable: vi.fn(() => true),
+ searchDocsExecute: vi.fn(),
}))
vi.mock('@/blocks/registry', () => ({ getBlock, getBlockRegistry }))
@@ -57,6 +60,9 @@ vi.mock('@/lib/table/rows/service', () => ({ getRowsByIds }))
vi.mock('@/lib/knowledge/application/knowledge-bases', () => ({
readKnowledgeBase: { execute: readKnowledgeBase },
}))
+vi.mock('@/lib/copilot/tools/server/docs/search-docs', () => ({
+ searchDocsServerTool: { execute: searchDocsExecute },
+}))
/**
* Overrides the global `@sim/db` mock: the logs-context tests below need
@@ -282,6 +288,121 @@ describe('processContextsServer - skill contexts', () => {
})
})
+describe('processContextsServer - docs contexts', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('routes @Docs to an unscoped search_docs query', async () => {
+ const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry()
+ const results = [
+ {
+ path: 'docs/workflows/loops.mdx',
+ url: 'https://docs.sim.ai/workflows/loops',
+ title: 'Loops',
+ content: 'Use a loop block to iterate.',
+ similarity: 0.9,
+ },
+ ]
+ searchDocsExecute.mockResolvedValue({ results, query: 'how do loops work?', totalResults: 1 })
+
+ const result = await processContextsServer(
+ [{ kind: 'docs', label: 'Docs' }],
+ 'user-1',
+ '@Docs how do loops work?',
+ 'ws-1',
+ undefined,
+ resolvedSecretTraceRegistry
+ )
+
+ expect(searchDocsExecute).toHaveBeenCalledWith(
+ { query: 'how do loops work?' },
+ {
+ userId: 'user-1',
+ workspaceId: 'ws-1',
+ chatId: undefined,
+ resolvedSecretTraceRegistry,
+ }
+ )
+ expect(result).toEqual([
+ {
+ type: 'docs',
+ tag: '@Docs',
+ content: JSON.stringify({ results }),
+ },
+ ])
+ })
+
+ it('preserves the search note when @Docs has no relevant matches', async () => {
+ const note =
+ 'No relevant matches. This does NOT mean the docs lack this topic. Rephrase the query.'
+ searchDocsExecute.mockResolvedValue({ results: [], query: 'new topic', totalResults: 0, note })
+
+ const result = await processContextsServer(
+ [{ kind: 'docs', label: 'Docs' }],
+ 'user-1',
+ '@Docs new topic',
+ 'ws-1',
+ undefined,
+ new ResolvedSecretTraceRegistry()
+ )
+
+ expect(result).toEqual([
+ {
+ type: 'docs',
+ tag: '@Docs',
+ content: JSON.stringify({ results: [], note }),
+ },
+ ])
+ })
+
+ it('uses the Docs label when the message only contains the mention', async () => {
+ searchDocsExecute.mockResolvedValue({ results: [], query: 'Docs', totalResults: 0 })
+
+ await processContextsServer(
+ [{ kind: 'docs', label: 'Docs' }],
+ 'user-1',
+ '@Docs',
+ 'ws-1',
+ 'chat-1',
+ new ResolvedSecretTraceRegistry()
+ )
+
+ expect(searchDocsExecute).toHaveBeenCalledWith(
+ { query: 'Docs' },
+ expect.objectContaining({ workspaceId: 'ws-1', chatId: 'chat-1' })
+ )
+ })
+
+ it('preserves an explicit unavailable note when docs search fails', async () => {
+ searchDocsExecute.mockRejectedValue(new Error('embedding service unavailable'))
+
+ const result = await processContextsServer(
+ [{ kind: 'docs', label: 'Docs' }],
+ 'user-1',
+ '@Docs explain schedules',
+ 'ws-1',
+ 'chat-1',
+ new ResolvedSecretTraceRegistry()
+ )
+
+ expect(result).toEqual([
+ {
+ type: 'docs',
+ tag: '@Docs',
+ content: JSON.stringify({
+ results: [],
+ note: 'Documentation search is temporarily unavailable. Do not infer that the docs lack this topic; retry search_docs or browse docs/** later.',
+ }),
+ },
+ ])
+ expect(mockProcessContentsLogger.error).toHaveBeenCalledWith(
+ 'Failed to process docs context',
+ expect.any(Error)
+ )
+ })
+})
+
describe('processContextsServer - MCP contexts', () => {
beforeEach(() => {
vi.clearAllMocks()
diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts
index 8765e334f1..faa77afa33 100644
--- a/apps/sim/lib/copilot/chat/process-contents.ts
+++ b/apps/sim/lib/copilot/chat/process-contents.ts
@@ -49,6 +49,7 @@ import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/rea
import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { escapeRegExp } from '@/executor/constants'
+import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
import type { BrowserTextSelection, ChatContext, TerminalTextSelection } from '@/stores/panel'
type AgentContextType =
@@ -124,7 +125,8 @@ export async function processContextsServer(
userId: string,
userMessage?: string,
currentWorkspaceId?: string,
- chatId?: string
+ chatId?: string,
+ resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry
): Promise {
if (!Array.isArray(contexts) || contexts.length === 0) return []
const tasks = contexts.map(async (ctx) => {
@@ -314,17 +316,36 @@ export async function processContextsServer(
}
if (ctx.kind === 'docs') {
try {
- const { searchDocumentationServerTool } = await import(
- '@/lib/copilot/tools/server/docs/search-documentation'
+ const { searchDocsServerTool } = await import(
+ '@/lib/copilot/tools/server/docs/search-docs'
)
const rawQuery = (userMessage || '').trim() || ctx.label || 'Sim documentation'
- const query = sanitizeMessageForDocs(rawQuery, contexts)
- const res = await searchDocumentationServerTool.execute({ query, topK: 10 })
- const content = JSON.stringify(res?.results || [])
+ const query =
+ sanitizeMessageForDocs(rawQuery, contexts) || ctx.label || 'Sim documentation'
+ const res = await searchDocsServerTool.execute(
+ { query },
+ {
+ userId,
+ workspaceId: currentWorkspaceId,
+ chatId,
+ resolvedSecretTraceRegistry,
+ }
+ )
+ const content = JSON.stringify({
+ results: res?.results || [],
+ ...(res?.note ? { note: res.note } : {}),
+ })
return { type: 'docs', tag: ctx.label ? `@${ctx.label}` : '@', content }
} catch (e) {
logger.error('Failed to process docs context', e)
- return null
+ return {
+ type: 'docs',
+ tag: ctx.label ? `@${ctx.label}` : '@',
+ content: JSON.stringify({
+ results: [],
+ note: 'Documentation search is temporarily unavailable. Do not infer that the docs lack this topic; retry search_docs or browse docs/** later.',
+ }),
+ }
}
}
return null
diff --git a/apps/sim/lib/copilot/constants.ts b/apps/sim/lib/copilot/constants.ts
index 5102f753d5..1df1be6c40 100644
--- a/apps/sim/lib/copilot/constants.ts
+++ b/apps/sim/lib/copilot/constants.ts
@@ -36,6 +36,19 @@ export const TOOL_WATCHDOG_RESUME_GRACE_MS = 30_000
/** Timeout for the client-side streaming response handler (60 min). */
export const STREAM_TIMEOUT_MS = 3_600_000
+/**
+ * How long a workflow tool call waits for a browser to pick it up before the
+ * server runs it itself.
+ *
+ * Workflow tools are client-routed, but the only thing that starts one is the
+ * mounted chat view — a call frame that arrives while the user is on a
+ * different chat is never dispatched by anyone, and the turn used to park for
+ * the full STREAM_TIMEOUT_MS. The real pickup path (stream frame -> execute
+ * POST -> claim) lands in ~1-3s, so 30s is an order of magnitude of headroom
+ * and cannot steal work from a live tab.
+ */
+export const COPILOT_WORKFLOW_TOOL_CLIENT_GRACE_MS = 30_000
+
/** SessionStorage key for persisting active stream metadata across page reloads. */
export const STREAM_STORAGE_KEY = 'copilot_active_stream'
diff --git a/apps/sim/lib/copilot/docs/docs-corpus.test.ts b/apps/sim/lib/copilot/docs/docs-corpus.test.ts
new file mode 100644
index 0000000000..54764c3562
--- /dev/null
+++ b/apps/sim/lib/copilot/docs/docs-corpus.test.ts
@@ -0,0 +1,235 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockSleep } = vi.hoisted(() => ({
+ mockSleep: vi.fn(() => Promise.resolve()),
+}))
+
+vi.mock('@sim/utils/helpers', () => ({
+ sleep: mockSleep,
+}))
+
+import {
+ couldMatchDocsScope,
+ DocsCorpusError,
+ globDocs,
+ grepDocs,
+ isDocsPath,
+ readDocsPage,
+} from '@/lib/copilot/docs/docs-corpus'
+import { DOCS_MANIFEST } from '@/lib/copilot/generated/docs-manifest'
+
+const SAMPLE_PAGE = DOCS_MANIFEST.find((path) => path === 'workflows/blocks/agent.mdx')
+
+function fetchResponse(status: number, content = '', headers: HeadersInit = {}) {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ headers: new Headers(headers),
+ text: async () => content,
+ }
+}
+
+describe('docs corpus scoping', () => {
+ it('recognizes docs paths', () => {
+ expect(isDocsPath('docs/workflows.mdx')).toBe(true)
+ expect(isDocsPath('docs')).toBe(true)
+ expect(isDocsPath('/docs/workflows.mdx')).toBe(true)
+ expect(isDocsPath('workflows.mdx')).toBe(false)
+ expect(isDocsPath('files/report.pdf')).toBe(false)
+ expect(isDocsPath('docsomething/x')).toBe(false)
+ expect(isDocsPath(undefined)).toBe(false)
+ })
+
+ it('is opt-in: only an explicit docs/ pattern can match', () => {
+ expect(couldMatchDocsScope('docs/**')).toBe(true)
+ expect(couldMatchDocsScope('docs/workflows/**')).toBe(true)
+ expect(couldMatchDocsScope('**')).toBe(false)
+ expect(couldMatchDocsScope('**/*.mdx')).toBe(false)
+ expect(couldMatchDocsScope('*')).toBe(false)
+ expect(couldMatchDocsScope(undefined)).toBe(false)
+ })
+})
+
+describe('globDocs', () => {
+ it('lists the whole corpus under docs/**', () => {
+ const files = globDocs('docs/**')
+ expect(files.length).toBeGreaterThan(DOCS_MANIFEST.length)
+ expect(files).toContain('docs/workflows/blocks/agent.mdx')
+ expect(files).toContain('docs/workflows/blocks')
+ })
+
+ it('scopes to a section', () => {
+ const files = globDocs('docs/integrations/*.mdx')
+ expect(files).toContain('docs/integrations/gmail.mdx')
+ expect(files.every((path) => path.startsWith('docs/integrations/'))).toBe(true)
+ })
+
+ it('excludes academy and api-reference', () => {
+ expect(globDocs('docs/academy/**')).toEqual([])
+ expect(globDocs('docs/api-reference/**')).toEqual([])
+ })
+
+ it('maps section index pages onto their parent URL path', () => {
+ expect(globDocs('docs/workflows.mdx')).toEqual(['docs/workflows.mdx'])
+ expect(globDocs('docs/workflows/index.mdx')).toEqual([])
+ })
+
+ it('treats a trailing-slash pattern like the bare directory instead of matching nothing', () => {
+ expect(globDocs('docs/')).toEqual(['docs'])
+ expect(globDocs('docs/integrations/')).toEqual(['docs/integrations'])
+ })
+})
+
+describe('readDocsPage', () => {
+ const fetchMock = vi.fn()
+
+ beforeEach(() => {
+ fetchMock.mockReset()
+ mockSleep.mockReset()
+ mockSleep.mockResolvedValue(undefined)
+ vi.stubGlobal('fetch', fetchMock)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('fetches the manifest path verbatim from the docs site', async () => {
+ expect(SAMPLE_PAGE).toBeDefined()
+ fetchMock.mockResolvedValue(fetchResponse(200, '# Agent\n\nbody'))
+
+ const page = await readDocsPage(`docs/${SAMPLE_PAGE}`)
+
+ expect(fetchMock).toHaveBeenCalledOnce()
+ expect(fetchMock.mock.calls[0][0]).toBe(`https://docs.sim.ai/${SAMPLE_PAGE}`)
+ expect(page).toEqual({ content: '# Agent\n\nbody', totalLines: 3 })
+ })
+
+ it('rejects an unknown page without fetching', async () => {
+ await expect(readDocsPage('docs/not-a-real-page.mdx')).rejects.toThrow(DocsCorpusError)
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
+ it('points a directory read at glob', async () => {
+ await expect(readDocsPage('docs/workflows/blocks')).rejects.toThrow(/is a directory/)
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
+ it('surfaces a docs-site outage as a retryable error after exhausting retries', async () => {
+ fetchMock.mockResolvedValue(fetchResponse(502))
+ await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/could not be reached/)
+ expect(fetchMock).toHaveBeenCalledTimes(3)
+ })
+
+ it('treats a network failure as retryable', async () => {
+ fetchMock.mockRejectedValue(new Error('socket hang up'))
+ await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/could not be reached/)
+ expect(fetchMock).toHaveBeenCalledTimes(3)
+ })
+
+ it('recovers when a transient failure clears on retry', async () => {
+ fetchMock
+ .mockRejectedValueOnce(new Error('socket hang up'))
+ .mockResolvedValue(fetchResponse(200, '# Agent\n\nbody'))
+
+ const page = await readDocsPage(`docs/${SAMPLE_PAGE}`)
+
+ expect(fetchMock).toHaveBeenCalledTimes(2)
+ expect(page).toEqual({ content: '# Agent\n\nbody', totalLines: 3 })
+ })
+
+ it('reports a page the site no longer serves as permanent, without retrying', async () => {
+ fetchMock.mockResolvedValue(fetchResponse(404))
+ const error = await readDocsPage(`docs/${SAMPLE_PAGE}`).catch((e) => e)
+ expect(error).toBeInstanceOf(DocsCorpusError)
+ expect(error.message).toMatch(/does not serve it/)
+ expect(error.message).toMatch(/retrying will not help/)
+ expect(error.message).not.toMatch(/could not be reached/)
+ expect(fetchMock).toHaveBeenCalledOnce()
+ })
+
+ it('honors Retry-After while retrying a 429 response', async () => {
+ fetchMock.mockResolvedValue(fetchResponse(429, '', { 'Retry-After': '7' }))
+ await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/could not be reached/)
+ expect(fetchMock).toHaveBeenCalledTimes(3)
+ expect(mockSleep).toHaveBeenNthCalledWith(1, 7_000)
+ expect(mockSleep).toHaveBeenNthCalledWith(2, 7_000)
+ })
+
+ it('treats 408 as retryable rather than a missing page', async () => {
+ fetchMock.mockResolvedValue(fetchResponse(408))
+ await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/could not be reached/)
+ expect(fetchMock).toHaveBeenCalledTimes(3)
+ })
+
+ it('aborts an in-flight fetch without retrying', async () => {
+ const controller = new AbortController()
+ fetchMock.mockImplementation((_url: string, init: RequestInit) => {
+ const signal = init.signal as AbortSignal
+ return new Promise((_resolve, reject) => {
+ signal.addEventListener('abort', () => reject(signal.reason), { once: true })
+ })
+ })
+
+ const request = readDocsPage(`docs/${SAMPLE_PAGE}`, controller.signal)
+ await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce())
+ controller.abort(new Error('user stopped docs read'))
+
+ await expect(request).rejects.toThrow('user stopped docs read')
+ expect(fetchMock).toHaveBeenCalledOnce()
+ expect(mockSleep).not.toHaveBeenCalled()
+ })
+
+ it('aborts retry backoff before starting another fetch', async () => {
+ const controller = new AbortController()
+ fetchMock.mockResolvedValue(fetchResponse(502))
+ mockSleep.mockImplementationOnce(() => new Promise(() => {}))
+
+ const request = readDocsPage(`docs/${SAMPLE_PAGE}`, controller.signal)
+ await vi.waitFor(() => expect(mockSleep).toHaveBeenCalledOnce())
+ controller.abort(new Error('user stopped docs retry'))
+
+ await expect(request).rejects.toThrow('user stopped docs retry')
+ expect(fetchMock).toHaveBeenCalledOnce()
+ })
+})
+
+describe('grepDocs', () => {
+ const fetchMock = vi.fn()
+ const SECTION_DIR = 'docs/workflows/blocks'
+
+ beforeEach(() => {
+ fetchMock.mockReset()
+ vi.stubGlobal('fetch', fetchMock)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('greps exactly one page for a page path', async () => {
+ fetchMock.mockResolvedValue(fetchResponse(200, 'intro line\nsystemPrompt matters\ntail'))
+
+ const matches = await grepDocs(`docs/${SAMPLE_PAGE}`, 'systemPrompt')
+
+ expect(fetchMock).toHaveBeenCalledOnce()
+ expect(matches).toEqual([
+ { path: `docs/${SAMPLE_PAGE}`, line: 2, content: 'systemPrompt matters' },
+ ])
+ })
+
+ it('rejects a directory without fetching any pages', async () => {
+ await expect(grepDocs(SECTION_DIR, 'cron marker')).rejects.toThrow(
+ /grep must target one docs page/
+ )
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
+ it('rejects a path that is neither a page nor a directory without fetching', async () => {
+ await expect(grepDocs('docs/not-a-real-page.mdx', 'cron')).rejects.toThrow(/not a docs page/)
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/lib/copilot/docs/docs-corpus.ts b/apps/sim/lib/copilot/docs/docs-corpus.ts
new file mode 100644
index 0000000000..e8eca75761
--- /dev/null
+++ b/apps/sim/lib/copilot/docs/docs-corpus.ts
@@ -0,0 +1,251 @@
+import { createLogger } from '@sim/logger'
+import { toError } from '@sim/utils/errors'
+import { sleep } from '@sim/utils/helpers'
+import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry'
+import { foldDocsIndexPath } from '@/lib/copilot/docs/docs-path'
+import { DOCS_MANIFEST } from '@/lib/copilot/generated/docs-manifest'
+import type { GrepCountEntry, GrepMatch, GrepOptions } from '@/lib/copilot/vfs/operations'
+import { glob as globPaths, grepReadResult } from '@/lib/copilot/vfs/operations'
+
+const logger = createLogger('DocsCorpus')
+
+/** The public docs site the `docs/` tree is a lazy view of. */
+const DOCS_BASE_URL = 'https://docs.sim.ai'
+
+/** VFS prefix the docs corpus is mounted at. */
+const DOCS_PREFIX = 'docs/'
+
+/** Per-attempt budget — the site is CDN-cached and normally answers in well under a second. */
+const FETCH_ATTEMPT_TIMEOUT_MS = 3_000
+const FETCH_MAX_ATTEMPTS = 3
+
+/**
+ * Thrown for expected, user-facing docs-corpus conditions (unknown page,
+ * directory path, site unreachable). The VFS handlers return the message as the
+ * tool error instead of logging an internal failure.
+ */
+export class DocsCorpusError extends Error {
+ constructor(message: string) {
+ super(message)
+ this.name = 'DocsCorpusError'
+ }
+}
+
+/**
+ * Keys-only view of the corpus for glob: every manifest path under `docs/`,
+ * mapped to empty content. `ops.glob` matches keys and derives the virtual
+ * directories from them, so this never touches the network.
+ */
+const docsKeyView: Map = new Map(
+ DOCS_MANIFEST.map((path) => [`${DOCS_PREFIX}${path}`, ''])
+)
+
+/**
+ * Normalize a docs path and make `docs/` equivalent to `docs`, avoiding empty
+ * glob results caused only by a trailing slash.
+ */
+export function normalizeDocsPath(path: string): string {
+ return path.trim().replace(/^\/+/, '').replace(/\/+$/, '')
+}
+
+/**
+ * True when a read/grep `path` addresses the docs corpus. Deliberately not a
+ * `path is string` type predicate: the callers chain it ahead of the other
+ * namespace checks, and a predicate would narrow `path` to `never` in every
+ * later branch.
+ */
+export function isDocsPath(path: string | undefined): boolean {
+ if (!path) return false
+ const normalized = normalizeDocsPath(path)
+ return normalized === 'docs' || normalized.startsWith(DOCS_PREFIX)
+}
+
+/**
+ * True when a glob `pattern` could match the docs corpus. Like `uploads/` and
+ * `recently-deleted/`, the corpus is opt-in: only a pattern that explicitly
+ * starts with `docs/` (or is exactly `docs`) sees it, so a broad `**` glob never
+ * drags 300+ doc pages into the result. Same rule as {@link isDocsPath}; the
+ * separate name reads correctly at the glob call site.
+ */
+export function couldMatchDocsScope(pattern: string | undefined): boolean {
+ return isDocsPath(pattern)
+}
+
+/** Manifest paths (and their virtual directories) matching an explicit `docs/` pattern. */
+export function globDocs(pattern: string): string[] {
+ return globPaths(docsKeyView, normalizeDocsPath(pattern))
+}
+
+/** True when `path` is a page in the docs tree. */
+export function isDocsPage(path: string): boolean {
+ return docsKeyView.has(normalizeDocsPath(path))
+}
+
+/**
+ * Map a `docs_embeddings.source_document` (the en-relative mdx file path) back to
+ * its `docs/` VFS path, applying the same index-page fold as the manifest
+ * generator. Returns null when the source has no live VFS path — an unmounted
+ * section (academy, api-reference) or a page deleted since the index was built.
+ */
+export function docsPathForSourceDocument(sourceDocument: string | null): string | null {
+ if (!sourceDocument) return null
+ const path = `${DOCS_PREFIX}${foldDocsIndexPath(sourceDocument.replace(/^\/+/, ''))}`
+ return docsKeyView.has(path) ? path : null
+}
+
+/** True when `path` is a directory in the docs tree rather than a page. */
+export function isDocsDir(path: string): boolean {
+ const dir = `${normalizeDocsPath(path).replace(/\/+$/, '')}/`
+ if (dir === DOCS_PREFIX) return true
+ for (const key of docsKeyView.keys()) {
+ if (key.startsWith(dir)) return true
+ }
+ return false
+}
+
+export interface DocsPage {
+ content: string
+ totalLines: number
+}
+
+/**
+ * Fetch one docs page's raw markdown from the live site. The manifest path IS
+ * the URL path (`docs/workflows/blocks/agent.mdx` →
+ * `https://docs.sim.ai/workflows/blocks/agent.mdx`, which the docs app rewrites
+ * to its raw-markdown route), so no mapping table is needed. Transient failures
+ * (5xx, 429, network error, timeout) are retried with jittered backoff before
+ * being reported as unavailable.
+ */
+type DocsFetchResult =
+ | { outcome: 'ok'; content: string }
+ /** The site will not serve this path however many times we ask. */
+ | { outcome: 'missing' }
+ /** Transient: 5xx, 429, network error, or timeout. */
+ | { outcome: 'unavailable'; retryAfterMs: number | null }
+
+function throwIfAborted(signal?: AbortSignal): void {
+ if (signal?.aborted) {
+ throw toError(signal.reason ?? 'Docs request aborted')
+ }
+}
+
+async function sleepForRetry(delayMs: number, signal?: AbortSignal): Promise {
+ if (!signal) {
+ await sleep(delayMs)
+ return
+ }
+
+ throwIfAborted(signal)
+ let abortListener: (() => void) | undefined
+ const aborted = new Promise((_resolve, reject) => {
+ abortListener = () => reject(toError(signal.reason ?? 'Docs request aborted'))
+ signal.addEventListener('abort', abortListener, { once: true })
+ if (signal.aborted) abortListener()
+ })
+
+ try {
+ await Promise.race([sleep(delayMs), aborted])
+ } finally {
+ if (abortListener) signal.removeEventListener('abort', abortListener)
+ }
+}
+
+async function fetchDocsPageOnce(url: string, signal?: AbortSignal): Promise {
+ throwIfAborted(signal)
+ const timeoutSignal = AbortSignal.timeout(FETCH_ATTEMPT_TIMEOUT_MS)
+ const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal
+
+ try {
+ const response = await fetch(url, {
+ signal: requestSignal,
+ headers: { Accept: 'text/markdown, text/plain' },
+ })
+ if (!response.ok) {
+ logger.warn('Docs page fetch returned a non-OK status', { url, status: response.status })
+ const permanent =
+ response.status >= 400 &&
+ response.status < 500 &&
+ response.status !== 408 &&
+ response.status !== 429
+ if (permanent) return { outcome: 'missing' }
+ return {
+ outcome: 'unavailable',
+ retryAfterMs: parseRetryAfter(response.headers.get('retry-after')),
+ }
+ }
+ return { outcome: 'ok', content: await response.text() }
+ } catch (err) {
+ throwIfAborted(signal)
+ logger.warn('Docs page fetch failed', { url, error: toError(err).message })
+ return { outcome: 'unavailable', retryAfterMs: null }
+ }
+}
+
+async function fetchDocsPage(path: string, signal?: AbortSignal): Promise {
+ const key = normalizeDocsPath(path)
+ if (!docsKeyView.has(key)) return { outcome: 'missing' }
+ const url = `${DOCS_BASE_URL}/${key.slice(DOCS_PREFIX.length)}`
+ for (let attempt = 1; ; attempt++) {
+ throwIfAborted(signal)
+ const result = await fetchDocsPageOnce(url, signal)
+ throwIfAborted(signal)
+ if (result.outcome !== 'unavailable' || attempt >= FETCH_MAX_ATTEMPTS) return result
+ await sleepForRetry(backoffWithJitter(attempt, result.retryAfterMs), signal)
+ }
+}
+
+/**
+ * Read one docs page. Throws {@link DocsCorpusError} for the expected user-facing
+ * conditions (directory path, unknown page, site unreachable) so the handler can
+ * surface the message verbatim.
+ */
+export async function readDocsPage(path: string, signal?: AbortSignal): Promise {
+ const key = normalizeDocsPath(path)
+ if (!docsKeyView.has(key)) {
+ if (isDocsDir(key)) {
+ const dir = key.replace(/\/+$/, '')
+ throw new DocsCorpusError(`${dir} is a directory — glob "${dir}/**" to list its pages.`)
+ }
+ throw new DocsCorpusError(
+ `Docs page not found: ${path}. Use glob("docs/**") to list the docs corpus.`
+ )
+ }
+ const result = await fetchDocsPage(key, signal)
+ if (result.outcome === 'missing') {
+ throw new DocsCorpusError(
+ `${key} is in the docs index but ${DOCS_BASE_URL} does not serve it — the page was likely moved or removed. Use glob("docs/**") to find the current path; retrying will not help.`
+ )
+ }
+ if (result.outcome === 'unavailable') {
+ throw new DocsCorpusError(
+ `Could not load ${key} from ${DOCS_BASE_URL} — the docs site could not be reached. Retry shortly.`
+ )
+ }
+ return { content: result.content, totalLines: result.content.split('\n').length }
+}
+
+/**
+ * Grep one docs page. Directory-wide grep is deliberately unsupported because
+ * each page is a separate network fetch; use `search_docs` for corpus search or
+ * `glob("docs/**")` to find a page first.
+ */
+export async function grepDocs(
+ path: string,
+ pattern: string,
+ options?: GrepOptions,
+ signal?: AbortSignal
+): Promise {
+ const key = normalizeDocsPath(path)
+ if (!docsKeyView.has(key)) {
+ if (isDocsDir(key)) {
+ throw new DocsCorpusError(
+ `"${path}" is a docs directory; grep must target one docs page. Use search_docs to search the corpus or glob("${key}/**") to list its pages.`
+ )
+ }
+ throw new DocsCorpusError(
+ `"${path}" is not a docs page. Use glob("docs/**") to list the docs corpus.`
+ )
+ }
+ const page = await readDocsPage(key, signal)
+ return grepReadResult(key, page, pattern, key, options)
+}
diff --git a/apps/sim/lib/copilot/docs/docs-path.test.ts b/apps/sim/lib/copilot/docs/docs-path.test.ts
new file mode 100644
index 0000000000..c40ba0a7ab
--- /dev/null
+++ b/apps/sim/lib/copilot/docs/docs-path.test.ts
@@ -0,0 +1,35 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import { docsSourceCandidates, foldDocsIndexPath } from '@/lib/copilot/docs/docs-path'
+import { DOCS_MANIFEST } from '@/lib/copilot/generated/docs-manifest'
+
+describe('foldDocsIndexPath', () => {
+ it('folds a section overview onto the section path', () => {
+ expect(foldDocsIndexPath('workflows/index.mdx')).toBe('workflows.mdx')
+ expect(foldDocsIndexPath('platform/enterprise/index.mdx')).toBe('platform/enterprise.mdx')
+ })
+
+ it('leaves a plain page untouched', () => {
+ expect(foldDocsIndexPath('workflows/blocks/agent.mdx')).toBe('workflows/blocks/agent.mdx')
+ expect(foldDocsIndexPath('agents.mdx')).toBe('agents.mdx')
+ })
+
+ it('does not fold a page merely named index', () => {
+ expect(foldDocsIndexPath('index.mdx')).toBe('index.mdx')
+ })
+})
+
+describe('docsSourceCandidates', () => {
+ it('is the inverse of the fold — one candidate always reproduces the input', () => {
+ for (const publicPath of DOCS_MANIFEST) {
+ const candidates = docsSourceCandidates(publicPath)
+ expect(candidates.map(foldDocsIndexPath)).toContain(publicPath)
+ }
+ })
+
+ it('offers both on-disk layouts for a section path', () => {
+ expect(docsSourceCandidates('workflows.mdx')).toEqual(['workflows.mdx', 'workflows/index.mdx'])
+ })
+})
diff --git a/apps/sim/lib/copilot/docs/docs-path.ts b/apps/sim/lib/copilot/docs/docs-path.ts
new file mode 100644
index 0000000000..6178541803
--- /dev/null
+++ b/apps/sim/lib/copilot/docs/docs-path.ts
@@ -0,0 +1,44 @@
+/**
+ * The single definition of how a docs source file maps onto its public path.
+ *
+ * Fumadocs folds a section's `index.mdx` into the section URL itself, so
+ * `workflows/index.mdx` on disk is `/workflows` on the site (and
+ * `/workflows/index.mdx` is a 404). Three places need that rule — the manifest
+ * generator, the `source_document` -> VFS reverse mapping, and the vector
+ * search's scope filter — and hand-syncing it has bitten this repo before, so
+ * it lives here.
+ *
+ * Deliberately dependency-free: `scripts/sync-docs-manifest.ts` imports this by
+ * relative path, and it must not pull in the manifest it generates.
+ */
+
+/** Suffix that marks a section overview page on disk. */
+export const DOCS_INDEX_SUFFIX = '/index.mdx'
+
+/**
+ * Top-level docs sections deliberately left out of the copilot's `docs/` tree.
+ *
+ * The manifest generator and vector search share this list so search cannot
+ * return pages that the VFS cannot read.
+ */
+export const UNMOUNTED_DOCS_SECTIONS = ['academy', 'api-reference'] as const
+
+/**
+ * Fold an `en`-relative mdx file path onto its public path — the value used as
+ * both the `docs/`-relative VFS path and the docs.sim.ai URL path.
+ */
+export function foldDocsIndexPath(mdxPath: string): string {
+ return mdxPath.endsWith(DOCS_INDEX_SUFFIX)
+ ? `${mdxPath.slice(0, -DOCS_INDEX_SUFFIX.length)}.mdx`
+ : mdxPath
+}
+
+/**
+ * The inverse of {@link foldDocsIndexPath}: the on-disk file names a public
+ * path could have come from. A page is stored either as `.mdx` or, when
+ * it is a section overview, as `/index.mdx`.
+ */
+export function docsSourceCandidates(publicPath: string): [string, string] {
+ const stem = publicPath.replace(/\.mdx$/, '')
+ return [`${stem}.mdx`, `${stem}${DOCS_INDEX_SUFFIX}`]
+}
diff --git a/apps/sim/lib/copilot/docs/docs-search.test.ts b/apps/sim/lib/copilot/docs/docs-search.test.ts
new file mode 100644
index 0000000000..16d19141f4
--- /dev/null
+++ b/apps/sim/lib/copilot/docs/docs-search.test.ts
@@ -0,0 +1,292 @@
+/**
+ * @vitest-environment node
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockGenerateSearchEmbedding, capturedWhere, capturedLimit, mockRows } = vi.hoisted(() => ({
+ mockGenerateSearchEmbedding: vi.fn(),
+ capturedWhere: { value: undefined as unknown },
+ capturedLimit: { value: undefined as number | undefined },
+ mockRows: { value: [] as unknown[] },
+}))
+
+vi.mock('@/lib/knowledge/embeddings', () => ({
+ generateSearchEmbedding: mockGenerateSearchEmbedding,
+}))
+
+/**
+ * Override the global drizzle mock with operators that record their arguments,
+ * so a test can assert on the `source_document` filter the scope produced.
+ */
+vi.mock('drizzle-orm', () => {
+ const op =
+ (name: string) =>
+ (...args: unknown[]) => ({ op: name, args })
+ return {
+ and: op('and'),
+ or: op('or'),
+ eq: op('eq'),
+ ne: op('ne'),
+ like: op('like'),
+ notLike: op('notLike'),
+ sql: (strings: TemplateStringsArray) => ({ op: 'sql', text: strings.join('?') }),
+ }
+})
+
+vi.mock('@sim/db', () => ({
+ db: {
+ select: () => ({
+ from: () => ({
+ where: (condition: unknown) => {
+ capturedWhere.value = condition
+ return {
+ orderBy: () => ({
+ limit: async (n: number) => {
+ capturedLimit.value = n
+ return mockRows.value
+ },
+ }),
+ }
+ },
+ }),
+ }),
+ },
+}))
+
+import { DocsSearchScopeError, searchDocs } from '@/lib/copilot/docs/docs-search'
+import { OrchestrationError } from '@/lib/core/orchestration/types'
+
+/** Render a drizzle condition to comparable SQL-ish text for assertions. */
+function whereText(): string {
+ return JSON.stringify(capturedWhere.value)
+}
+
+describe('searchDocs path scoping', () => {
+ beforeEach(() => {
+ capturedWhere.value = undefined
+ mockRows.value = []
+ mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2] })
+ })
+
+ it('excludes unmounted sections when unscoped', async () => {
+ await searchDocs('cron')
+ expect(whereText()).toContain('academy/%')
+ expect(whereText()).toContain('api-reference/%')
+ })
+
+ it('treats a bare docs prefix as unscoped', async () => {
+ await searchDocs('cron', { path: 'docs/' })
+ expect(whereText()).toContain('academy/%')
+ })
+
+ it('excludes the root homepage when unscoped — its chunks have no live docs/ path', async () => {
+ await searchDocs('cron')
+ expect(whereText()).toContain('"op":"ne"')
+ expect(whereText()).toContain('index.mdx')
+ })
+
+ it('scopes a page to both on-disk layouts', async () => {
+ await searchDocs('cron', { path: 'docs/workflows/blocks/agent.mdx' })
+ const text = whereText()
+ expect(text).toContain('workflows/blocks/agent.mdx')
+ expect(text).toContain('workflows/blocks/agent/index.mdx')
+ })
+
+ it('maps a section overview page onto its index file', async () => {
+ await searchDocs('cron', { path: 'docs/workflows.mdx' })
+ const text = whereText()
+ expect(text).toContain('workflows/index.mdx')
+ })
+
+ it('scopes a directory to its subtree', async () => {
+ await searchDocs('cron', { path: 'docs/workflows' })
+ expect(whereText()).toContain('workflows/%')
+ })
+
+ it('includes a section overview stored in either on-disk layout', async () => {
+ await searchDocs('cron', { path: 'docs/workflows' })
+ const text = whereText()
+ expect(text).toContain('workflows/%')
+ expect(text).toContain('workflows.mdx')
+ })
+
+ it('rejects a path outside the docs corpus', async () => {
+ const error = await searchDocs('cron', { path: 'files/report.pdf' }).catch((cause) => cause)
+ expect(error).toBeInstanceOf(DocsSearchScopeError)
+ expect(error).toBeInstanceOf(OrchestrationError)
+ expect(error).toMatchObject({ code: 'validation' })
+ })
+
+ it('rejects a docs path that is neither a page nor a section', async () => {
+ await expect(searchDocs('cron', { path: 'docs/not-a-real-section' })).rejects.toThrow(
+ /not a page or section/
+ )
+ })
+
+ it('rejects unmounted sections that exist on the site but not in the VFS', async () => {
+ await expect(searchDocs('cron', { path: 'docs/academy' })).rejects.toThrow(
+ /not a page or section/
+ )
+ })
+})
+
+describe('searchDocs results', () => {
+ beforeEach(() => {
+ capturedWhere.value = undefined
+ mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2] })
+ })
+
+ it('returns the docs/ path to read next, folding index pages', async () => {
+ mockRows.value = [
+ {
+ chunkText: 'body',
+ sourceDocument: 'workflows/index.mdx',
+ sourceLink: 'https://docs.sim.ai/workflows',
+ headerText: 'Overview',
+ similarity: 0.8,
+ },
+ ]
+ const { results } = await searchDocs('cron')
+ expect(results).toEqual([
+ {
+ path: 'docs/workflows.mdx',
+ url: 'https://docs.sim.ai/workflows',
+ title: 'Overview',
+ content: 'body',
+ similarity: 0.8,
+ },
+ ])
+ })
+
+ it('drops chunks whose source has no live docs/ path', async () => {
+ mockRows.value = [
+ {
+ chunkText: 'a',
+ sourceDocument: 'academy/lesson-1.mdx',
+ sourceLink: 'x',
+ headerText: 'h',
+ similarity: 0.9,
+ },
+ {
+ chunkText: 'b',
+ sourceDocument: 'deleted-page.mdx',
+ sourceLink: 'y',
+ headerText: 'h',
+ similarity: 0.9,
+ },
+ ]
+ expect((await searchDocs('cron')).results).toEqual([])
+ })
+
+ it('returns the zero-candidate outcome without querying when the embedding is empty', async () => {
+ mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [] })
+ const outcome = await searchDocs('cron')
+ expect(outcome).toEqual({
+ results: [],
+ candidatesConsidered: 0,
+ droppedBelowThreshold: 0,
+ droppedStale: 0,
+ })
+ })
+
+ it('drops chunks below the similarity threshold', async () => {
+ mockRows.value = [
+ {
+ chunkText: 'a',
+ sourceDocument: 'agents.mdx',
+ sourceLink: 'x',
+ headerText: 'h',
+ similarity: 0.1,
+ },
+ ]
+ expect((await searchDocs('cron')).results).toEqual([])
+ })
+})
+
+describe('searchDocs shortfall reporting', () => {
+ beforeEach(() => {
+ capturedWhere.value = undefined
+ mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2] })
+ })
+
+ it('counts why candidates were dropped so an empty set is explainable', async () => {
+ mockRows.value = [
+ {
+ chunkText: 'a',
+ sourceDocument: 'agents.mdx',
+ sourceLink: 'x',
+ headerText: 'h',
+ similarity: 0.1,
+ },
+ {
+ chunkText: 'b',
+ sourceDocument: 'deleted-page.mdx',
+ sourceLink: 'y',
+ headerText: 'h',
+ similarity: 0.9,
+ },
+ ]
+ const outcome = await searchDocs('cron')
+ expect(outcome).toEqual({
+ results: [],
+ candidatesConsidered: 2,
+ droppedBelowThreshold: 1,
+ droppedStale: 1,
+ })
+ })
+
+ it('reports no drops when every candidate survives', async () => {
+ mockRows.value = [
+ {
+ chunkText: 'a',
+ sourceDocument: 'agents.mdx',
+ sourceLink: 'x',
+ headerText: 'h',
+ similarity: 0.9,
+ },
+ ]
+ const outcome = await searchDocs('cron')
+ expect(outcome.droppedBelowThreshold).toBe(0)
+ expect(outcome.droppedStale).toBe(0)
+ expect(outcome.results).toHaveLength(1)
+ })
+})
+
+describe('searchDocs topK clamping', () => {
+ beforeEach(() => {
+ capturedLimit.value = undefined
+ mockRows.value = []
+ mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2] })
+ })
+
+ it('defaults to 5 when unspecified', async () => {
+ await searchDocs('cron')
+ expect(capturedLimit.value).toBe(5)
+ })
+
+ it('caps at 25 — the documented max, which the old tool never enforced', async () => {
+ await searchDocs('cron', { topK: 500 })
+ expect(capturedLimit.value).toBe(25)
+ })
+
+ it('floors at 1', async () => {
+ await searchDocs('cron', { topK: 0 })
+ expect(capturedLimit.value).toBe(1)
+ await searchDocs('cron', { topK: -8 })
+ expect(capturedLimit.value).toBe(1)
+ })
+
+ it('truncates a fractional count', async () => {
+ await searchDocs('cron', { topK: 7.9 })
+ expect(capturedLimit.value).toBe(7)
+ })
+
+ it('falls back to the default rather than passing NaN to the query', async () => {
+ await searchDocs('cron', { topK: Number.NaN })
+ expect(capturedLimit.value).toBe(5)
+ await searchDocs('cron', { topK: 'twelve' as unknown as number })
+ expect(capturedLimit.value).toBe(5)
+ await searchDocs('cron', { topK: Number.POSITIVE_INFINITY })
+ expect(capturedLimit.value).toBe(5)
+ })
+})
diff --git a/apps/sim/lib/copilot/docs/docs-search.ts b/apps/sim/lib/copilot/docs/docs-search.ts
new file mode 100644
index 0000000000..e506dff48e
--- /dev/null
+++ b/apps/sim/lib/copilot/docs/docs-search.ts
@@ -0,0 +1,207 @@
+import { db } from '@sim/db'
+import { docsEmbeddings } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { and, eq, like, ne, notLike, or, sql } from 'drizzle-orm'
+import { escapeLikePattern } from '@/lib/api/list-query'
+import {
+ docsPathForSourceDocument,
+ isDocsDir,
+ isDocsPage,
+ normalizeDocsPath,
+} from '@/lib/copilot/docs/docs-corpus'
+import { docsSourceCandidates, UNMOUNTED_DOCS_SECTIONS } from '@/lib/copilot/docs/docs-path'
+import { OrchestrationError } from '@/lib/core/orchestration/types'
+import { generateSearchEmbedding } from '@/lib/knowledge/embeddings'
+
+const logger = createLogger('DocsSearch')
+
+const SIMILARITY_THRESHOLD = 0.3
+const DEFAULT_TOP_K = 5
+const MAX_TOP_K = 25
+
+export interface DocsSearchResult {
+ /** The `docs/` VFS path this chunk came from — pass it to `read` for the full page. */
+ path: string
+ /** Public docs.sim.ai URL for the section, for citation. */
+ url: string
+ title: string
+ content: string
+ similarity: number
+}
+
+/**
+ * A search result set plus why it may be shorter than `topK`. The SQL LIMIT is
+ * applied before the threshold and liveness filters, so these counts are what
+ * distinguishes "nothing matched" from "matches were filtered out".
+ */
+export interface DocsSearchOutcome {
+ results: DocsSearchResult[]
+ /** Rows the vector search returned before filtering. */
+ candidatesConsidered: number
+ /** Candidates dropped for scoring below the similarity threshold. */
+ droppedBelowThreshold: number
+ /** Candidates dropped because their page is no longer in the docs manifest. */
+ droppedStale: number
+}
+
+/**
+ * Thrown when the caller scopes a search to a `path` that is not a real page or
+ * section in the docs corpus. Surfaced verbatim so the model can correct itself
+ * rather than reading an empty result as "the docs say nothing about this".
+ */
+export class DocsSearchScopeError extends OrchestrationError {
+ constructor(message: string) {
+ super('validation', message)
+ this.name = 'DocsSearchScopeError'
+ }
+}
+
+/**
+ * Translate an optional `docs/` VFS path into a `source_document` filter.
+ *
+ * `source_document` stores the en-relative mdx file path, while VFS paths mirror
+ * the public URL — so a section overview is `docs/workflows.mdx` in the VFS but
+ * `workflows/index.mdx` (or `workflows.mdx`) on disk. A directory scope covers
+ * the whole subtree plus the overview in either layout.
+ *
+ * An unscoped search excludes every {@link UNMOUNTED_DOCS_SECTIONS} section:
+ * they are indexed but not mounted in the VFS, so a hit there would be a chunk
+ * the agent cannot then read. The root homepage (`index.mdx`) is excluded for
+ * the same reason — the manifest generator drops it (its URL is `/`, which
+ * redirects), so its chunks would only ever be counted against topK and then
+ * discarded as stale.
+ */
+function scopeCondition(path?: string) {
+ const normalized = normalizeDocsPath(path ?? '')
+ if (normalized === '' || normalized === 'docs') {
+ return and(
+ ne(docsEmbeddings.sourceDocument, 'index.mdx'),
+ ...UNMOUNTED_DOCS_SECTIONS.map((section) =>
+ notLike(docsEmbeddings.sourceDocument, `${section}/%`)
+ )
+ )
+ }
+
+ if (!normalized.startsWith('docs/')) {
+ throw new DocsSearchScopeError(
+ `path must be a docs/ VFS path (got "${path}"). Use glob("docs/**") to find one, or omit path to search everything.`
+ )
+ }
+
+ const tail = normalized.slice('docs/'.length)
+
+ if (isDocsPage(normalized)) {
+ const [pageFile, indexFile] = docsSourceCandidates(tail)
+ return or(
+ eq(docsEmbeddings.sourceDocument, pageFile),
+ eq(docsEmbeddings.sourceDocument, indexFile)
+ )
+ }
+
+ if (isDocsDir(normalized)) {
+ return or(
+ like(docsEmbeddings.sourceDocument, `${escapeLikePattern(tail)}/%`),
+ eq(docsEmbeddings.sourceDocument, `${tail}.mdx`)
+ )
+ }
+
+ throw new DocsSearchScopeError(
+ `"${path}" is not a page or section in the docs corpus. Use glob("docs/**") to find a valid path, or omit path to search everything.`
+ )
+}
+
+/**
+ * Clamp a caller-supplied result count into [1, {@link MAX_TOP_K}].
+ *
+ * Guards magnitude AND type: `Math.min`/`Math.max` propagate NaN, so a
+ * non-numeric value would otherwise reach the query as `.limit(NaN)`. The
+ * generated tool schema rejects a non-number upstream today, but this function
+ * is also called directly, so it does not rely on that.
+ */
+function clampTopK(requested: number | undefined): number {
+ if (requested === undefined || !Number.isFinite(requested)) return DEFAULT_TOP_K
+ return Math.min(Math.max(Math.trunc(requested), 1), MAX_TOP_K)
+}
+
+/**
+ * Semantic search over the indexed docs corpus (`docs_embeddings`, rebuilt by
+ * `scripts/process-docs.ts` on release). Every result carries the `docs/` path
+ * it came from so the caller can `read` the full page next.
+ *
+ * The index lags the VFS: a page added since the last index rebuild is readable
+ * but not searchable, and a deleted one can still return chunks. Results whose
+ * source no longer maps to a live `docs/` path are dropped.
+ *
+ * Because those drops happen after the SQL LIMIT, a caller can get fewer hits
+ * than it asked for — or none at all when every candidate was filtered. The
+ * returned {@link DocsSearchOutcome} reports that explicitly so an empty result
+ * is never mistaken for "the documentation does not cover this".
+ */
+export async function searchDocs(
+ query: string,
+ options?: { path?: string; topK?: number }
+): Promise {
+ if (!query || typeof query !== 'string') throw new Error('query is required')
+
+ const topK = clampTopK(options?.topK)
+ const where = scopeCondition(options?.path)
+
+ logger.info('Executing docs search', {
+ queryLength: query.length,
+ topK,
+ path: options?.path ?? null,
+ })
+
+ const { embedding: queryEmbedding } = await generateSearchEmbedding(query)
+ if (!queryEmbedding || queryEmbedding.length === 0) {
+ return { results: [], candidatesConsidered: 0, droppedBelowThreshold: 0, droppedStale: 0 }
+ }
+ const queryVector = JSON.stringify(queryEmbedding)
+
+ const rows = await db
+ .select({
+ chunkText: docsEmbeddings.chunkText,
+ sourceDocument: docsEmbeddings.sourceDocument,
+ sourceLink: docsEmbeddings.sourceLink,
+ headerText: docsEmbeddings.headerText,
+ similarity: sql`1 - (${docsEmbeddings.embedding} <=> ${queryVector}::vector)`,
+ })
+ .from(docsEmbeddings)
+ .where(where)
+ .orderBy(sql`${docsEmbeddings.embedding} <=> ${queryVector}::vector`)
+ .limit(topK)
+
+ const results: DocsSearchResult[] = []
+ let droppedBelowThreshold = 0
+ let droppedStale = 0
+ for (const row of rows) {
+ if (row.similarity < SIMILARITY_THRESHOLD) {
+ droppedBelowThreshold++
+ continue
+ }
+ const path = docsPathForSourceDocument(row.sourceDocument)
+ if (!path) {
+ droppedStale++
+ continue
+ }
+ results.push({
+ path,
+ url: row.sourceLink,
+ title: row.headerText,
+ content: row.chunkText,
+ similarity: row.similarity,
+ })
+ }
+
+ logger.info('Docs search complete', {
+ count: results.length,
+ droppedBelowThreshold,
+ droppedStale,
+ })
+ return {
+ results,
+ candidatesConsidered: rows.length,
+ droppedBelowThreshold,
+ droppedStale,
+ }
+}
diff --git a/apps/sim/lib/copilot/entitlements.ts b/apps/sim/lib/copilot/entitlements.ts
index f35e356b8d..99219bc837 100644
--- a/apps/sim/lib/copilot/entitlements.ts
+++ b/apps/sim/lib/copilot/entitlements.ts
@@ -3,6 +3,7 @@ import { getErrorMessage } from '@sim/utils/errors'
import { LRUCache } from 'lru-cache'
import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription'
import { isCustomBlocksEligible } from '@/lib/workflows/custom-blocks/operations'
+import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('CopilotEntitlements')
@@ -12,6 +13,7 @@ const logger = createLogger('CopilotEntitlements')
*/
export const CUSTOM_BLOCKS_ENTITLEMENT = 'custom-blocks'
export const SIM_SANDBOXES_ENTITLEMENT = 'sim-sandboxes'
+export const ORGANIZATION_CONTEXT_ENTITLEMENT = 'organization-context'
/**
* Workspace entitlements — plan/flag-gated org capabilities sent to the
@@ -36,6 +38,18 @@ const ENTITLEMENT_EVALUATORS: Record<
> = {
[CUSTOM_BLOCKS_ENTITLEMENT]: isCustomBlocksEligible,
[SIM_SANDBOXES_ENTITLEMENT]: hasWorkspaceSandboxAccess,
+ [ORGANIZATION_CONTEXT_ENTITLEMENT]: isOrganizationContextAvailable,
+}
+
+/**
+ * True when this workspace belongs to an organization, which is exactly when
+ * the copilot's `organization/` VFS namespace has anything in it. Advertising
+ * it keeps a personal workspace's agents from ever hearing that org standing,
+ * access-control groups, or fork topology exist.
+ */
+async function isOrganizationContextAvailable(workspaceId: string): Promise {
+ const workspace = await getWorkspaceWithOwner(workspaceId)
+ return Boolean(workspace?.organizationId)
}
const entitlementsCache = new LRUCache>({
diff --git a/apps/sim/lib/copilot/generated/docs-manifest.ts b/apps/sim/lib/copilot/generated/docs-manifest.ts
new file mode 100644
index 0000000000..6adc3e3217
--- /dev/null
+++ b/apps/sim/lib/copilot/generated/docs-manifest.ts
@@ -0,0 +1,422 @@
+/**
+ * AUTO-GENERATED FILE. DO NOT EDIT.
+ * Generated from apps/docs/content/docs/en by scripts/sync-docs-manifest.ts.
+ * Run: bun run docs-manifest:generate.
+ *
+ * Every page in the copilot's read-only `docs/` VFS tree, as a path that is
+ * simultaneously the `docs/`-relative VFS path and the docs.sim.ai URL path
+ * (so `docs/workflows/blocks/agent.mdx` reads
+ * `https://docs.sim.ai/workflows/blocks/agent.mdx`). Sorted.
+ */
+export const DOCS_MANIFEST: readonly string[] = [
+ 'agents.mdx',
+ 'agents/choosing.mdx',
+ 'agents/custom-tools.mdx',
+ 'agents/mcp.mdx',
+ 'agents/skills.mdx',
+ 'chat.mdx',
+ 'chat/files.mdx',
+ 'chat/knowledge.mdx',
+ 'chat/mailer.mdx',
+ 'chat/research.mdx',
+ 'chat/tables.mdx',
+ 'chat/tasks.mdx',
+ 'chat/workflows.mdx',
+ 'cli.mdx',
+ 'cli/audit-logs.mdx',
+ 'cli/authentication.mdx',
+ 'cli/billing.mdx',
+ 'cli/commands.mdx',
+ 'cli/configuration.mdx',
+ 'cli/credentials.mdx',
+ 'cli/custom-tools.mdx',
+ 'cli/files.mdx',
+ 'cli/knowledge.mdx',
+ 'cli/logs.mdx',
+ 'cli/mcp-servers.mdx',
+ 'cli/output.mdx',
+ 'cli/reference.mdx',
+ 'cli/scripting.mdx',
+ 'cli/secrets.mdx',
+ 'cli/skills.mdx',
+ 'cli/tables.mdx',
+ 'cli/troubleshooting.mdx',
+ 'cli/workflows.mdx',
+ 'cli/workspaces.mdx',
+ 'files.mdx',
+ 'files/editor.mdx',
+ 'files/generating.mdx',
+ 'files/passing-files.mdx',
+ 'files/using-in-workflows.mdx',
+ 'getting-started.mdx',
+ 'integrations.mdx',
+ 'integrations/a2a.mdx',
+ 'integrations/agentmail.mdx',
+ 'integrations/agentphone.mdx',
+ 'integrations/agiloft.mdx',
+ 'integrations/ahrefs.mdx',
+ 'integrations/airtable-service-account.mdx',
+ 'integrations/airtable.mdx',
+ 'integrations/airweave.mdx',
+ 'integrations/algolia.mdx',
+ 'integrations/amplitude.mdx',
+ 'integrations/apify.mdx',
+ 'integrations/apollo.mdx',
+ 'integrations/appconfig.mdx',
+ 'integrations/arxiv.mdx',
+ 'integrations/asana-service-account.mdx',
+ 'integrations/asana.mdx',
+ 'integrations/ashby.mdx',
+ 'integrations/athena.mdx',
+ 'integrations/atlassian-service-account.mdx',
+ 'integrations/attio-service-account.mdx',
+ 'integrations/attio.mdx',
+ 'integrations/azure_data_explorer.mdx',
+ 'integrations/azure_devops.mdx',
+ 'integrations/bitbucket.mdx',
+ 'integrations/box-service-account.mdx',
+ 'integrations/box.mdx',
+ 'integrations/brandfetch.mdx',
+ 'integrations/brex.mdx',
+ 'integrations/brightdata.mdx',
+ 'integrations/browser_use.mdx',
+ 'integrations/buffer.mdx',
+ 'integrations/calcom-service-account.mdx',
+ 'integrations/calcom.mdx',
+ 'integrations/calendly.mdx',
+ 'integrations/cbinsights.mdx',
+ 'integrations/circleback.mdx',
+ 'integrations/clay.mdx',
+ 'integrations/clerk.mdx',
+ 'integrations/clickhouse.mdx',
+ 'integrations/clickup-service-account.mdx',
+ 'integrations/clickup.mdx',
+ 'integrations/cloudflare.mdx',
+ 'integrations/cloudformation.mdx',
+ 'integrations/cloudwatch.mdx',
+ 'integrations/codepipeline.mdx',
+ 'integrations/confluence.mdx',
+ 'integrations/context_dev.mdx',
+ 'integrations/convex.mdx',
+ 'integrations/crowdstrike.mdx',
+ 'integrations/crunchbase.mdx',
+ 'integrations/cursor.mdx',
+ 'integrations/dagster.mdx',
+ 'integrations/databricks.mdx',
+ 'integrations/datadog.mdx',
+ 'integrations/datagma.mdx',
+ 'integrations/daytona.mdx',
+ 'integrations/deployments.mdx',
+ 'integrations/devin.mdx',
+ 'integrations/discord.mdx',
+ 'integrations/docusign.mdx',
+ 'integrations/downdetector.mdx',
+ 'integrations/dropbox.mdx',
+ 'integrations/dropcontact.mdx',
+ 'integrations/dspy.mdx',
+ 'integrations/dub.mdx',
+ 'integrations/duckduckgo.mdx',
+ 'integrations/dynamodb.mdx',
+ 'integrations/dynatrace.mdx',
+ 'integrations/elasticsearch.mdx',
+ 'integrations/elevenlabs.mdx',
+ 'integrations/emailbison.mdx',
+ 'integrations/embeddings.mdx',
+ 'integrations/enrich.mdx',
+ 'integrations/enrichment.mdx',
+ 'integrations/enrow.mdx',
+ 'integrations/exa.mdx',
+ 'integrations/extend.mdx',
+ 'integrations/fathom.mdx',
+ 'integrations/file.mdx',
+ 'integrations/findymail.mdx',
+ 'integrations/firecrawl.mdx',
+ 'integrations/fireflies.mdx',
+ 'integrations/flint.mdx',
+ 'integrations/gamma.mdx',
+ 'integrations/github.mdx',
+ 'integrations/gitlab.mdx',
+ 'integrations/gmail.mdx',
+ 'integrations/gong.mdx',
+ 'integrations/google-service-account.mdx',
+ 'integrations/google_ads.mdx',
+ 'integrations/google_appsheet.mdx',
+ 'integrations/google_bigquery.mdx',
+ 'integrations/google_books.mdx',
+ 'integrations/google_calendar.mdx',
+ 'integrations/google_contacts.mdx',
+ 'integrations/google_docs.mdx',
+ 'integrations/google_drive.mdx',
+ 'integrations/google_forms.mdx',
+ 'integrations/google_groups.mdx',
+ 'integrations/google_maps.mdx',
+ 'integrations/google_meet.mdx',
+ 'integrations/google_pagespeed.mdx',
+ 'integrations/google_search.mdx',
+ 'integrations/google_sheets.mdx',
+ 'integrations/google_slides.mdx',
+ 'integrations/google_tasks.mdx',
+ 'integrations/google_translate.mdx',
+ 'integrations/google_vault.mdx',
+ 'integrations/grafana.mdx',
+ 'integrations/grain.mdx',
+ 'integrations/granola.mdx',
+ 'integrations/greenhouse.mdx',
+ 'integrations/greptile.mdx',
+ 'integrations/hex.mdx',
+ 'integrations/hubspot-service-account.mdx',
+ 'integrations/hubspot-setup.mdx',
+ 'integrations/hubspot.mdx',
+ 'integrations/huggingface.mdx',
+ 'integrations/hunter.mdx',
+ 'integrations/iam.mdx',
+ 'integrations/icypeas.mdx',
+ 'integrations/identity_center.mdx',
+ 'integrations/imap.mdx',
+ 'integrations/incidentio.mdx',
+ 'integrations/infisical.mdx',
+ 'integrations/instantly.mdx',
+ 'integrations/intercom.mdx',
+ 'integrations/jina.mdx',
+ 'integrations/jira.mdx',
+ 'integrations/jira_service_management.mdx',
+ 'integrations/jotform.mdx',
+ 'integrations/jupyter.mdx',
+ 'integrations/kalshi.mdx',
+ 'integrations/ketch.mdx',
+ 'integrations/knowledge.mdx',
+ 'integrations/langsmith.mdx',
+ 'integrations/latex.mdx',
+ 'integrations/launchdarkly.mdx',
+ 'integrations/leadmagic.mdx',
+ 'integrations/lemlist.mdx',
+ 'integrations/linear-service-account.mdx',
+ 'integrations/linear.mdx',
+ 'integrations/linkedin.mdx',
+ 'integrations/linkup.mdx',
+ 'integrations/linq.mdx',
+ 'integrations/logfire.mdx',
+ 'integrations/logrocket.mdx',
+ 'integrations/logs.mdx',
+ 'integrations/loops.mdx',
+ 'integrations/luma.mdx',
+ 'integrations/mailchimp.mdx',
+ 'integrations/mailgun.mdx',
+ 'integrations/managed_agent.mdx',
+ 'integrations/mem0.mdx',
+ 'integrations/memory.mdx',
+ 'integrations/microsoft_ad.mdx',
+ 'integrations/microsoft_dataverse.mdx',
+ 'integrations/microsoft_excel.mdx',
+ 'integrations/microsoft_planner.mdx',
+ 'integrations/microsoft_teams.mdx',
+ 'integrations/millionverifier.mdx',
+ 'integrations/mintlify.mdx',
+ 'integrations/mistral_parse.mdx',
+ 'integrations/modal.mdx',
+ 'integrations/monday-service-account.mdx',
+ 'integrations/monday.mdx',
+ 'integrations/mongodb.mdx',
+ 'integrations/mssql.mdx',
+ 'integrations/mysql.mdx',
+ 'integrations/neo4j.mdx',
+ 'integrations/netsuite-service-account.mdx',
+ 'integrations/netsuite.mdx',
+ 'integrations/neverbounce.mdx',
+ 'integrations/new_relic.mdx',
+ 'integrations/notion-service-account.mdx',
+ 'integrations/notion.mdx',
+ 'integrations/obsidian.mdx',
+ 'integrations/okta.mdx',
+ 'integrations/onedrive.mdx',
+ 'integrations/onepassword.mdx',
+ 'integrations/openai.mdx',
+ 'integrations/outlook.mdx',
+ 'integrations/pagerduty.mdx',
+ 'integrations/parallel_ai.mdx',
+ 'integrations/peopledatalabs.mdx',
+ 'integrations/perplexity.mdx',
+ 'integrations/persona.mdx',
+ 'integrations/pinecone.mdx',
+ 'integrations/pipedrive-service-account.mdx',
+ 'integrations/pipedrive.mdx',
+ 'integrations/pitchbook.mdx',
+ 'integrations/polymarket.mdx',
+ 'integrations/postgresql.mdx',
+ 'integrations/posthog.mdx',
+ 'integrations/profound.mdx',
+ 'integrations/prospeo.mdx',
+ 'integrations/pulse.mdx',
+ 'integrations/qdrant.mdx',
+ 'integrations/quartr.mdx',
+ 'integrations/quiver.mdx',
+ 'integrations/rabbitmq.mdx',
+ 'integrations/railway.mdx',
+ 'integrations/rb2b.mdx',
+ 'integrations/rds.mdx',
+ 'integrations/reddit.mdx',
+ 'integrations/redis.mdx',
+ 'integrations/reducto.mdx',
+ 'integrations/resend.mdx',
+ 'integrations/revenuecat.mdx',
+ 'integrations/rippling.mdx',
+ 'integrations/rocketlane.mdx',
+ 'integrations/rootly.mdx',
+ 'integrations/s3.mdx',
+ 'integrations/salesforce-service-account.mdx',
+ 'integrations/salesforce.mdx',
+ 'integrations/sap_concur.mdx',
+ 'integrations/sap_s4hana.mdx',
+ 'integrations/secrets_manager.mdx',
+ 'integrations/sendblue.mdx',
+ 'integrations/sendgrid.mdx',
+ 'integrations/sentry.mdx',
+ 'integrations/serper.mdx',
+ 'integrations/servicenow.mdx',
+ 'integrations/ses.mdx',
+ 'integrations/sftp.mdx',
+ 'integrations/sharepoint.mdx',
+ 'integrations/shopify-service-account.mdx',
+ 'integrations/shopify.mdx',
+ 'integrations/similarweb.mdx',
+ 'integrations/sixtyfour.mdx',
+ 'integrations/slack.mdx',
+ 'integrations/smartlead.mdx',
+ 'integrations/smtp.mdx',
+ 'integrations/snowflake-service-account.mdx',
+ 'integrations/snowflake.mdx',
+ 'integrations/splunk.mdx',
+ 'integrations/sportmonks.mdx',
+ 'integrations/sqs.mdx',
+ 'integrations/square.mdx',
+ 'integrations/ssh.mdx',
+ 'integrations/stagehand.mdx',
+ 'integrations/stripe.mdx',
+ 'integrations/sts.mdx',
+ 'integrations/supabase.mdx',
+ 'integrations/table.mdx',
+ 'integrations/tailscale.mdx',
+ 'integrations/tavily.mdx',
+ 'integrations/telegram.mdx',
+ 'integrations/temporal.mdx',
+ 'integrations/textract.mdx',
+ 'integrations/thrive.mdx',
+ 'integrations/tiktok.mdx',
+ 'integrations/tinybird.mdx',
+ 'integrations/trello-service-account.mdx',
+ 'integrations/trello.mdx',
+ 'integrations/trigger_dev.mdx',
+ 'integrations/twilio.mdx',
+ 'integrations/twilio_sms.mdx',
+ 'integrations/twilio_voice.mdx',
+ 'integrations/typeform.mdx',
+ 'integrations/upstash.mdx',
+ 'integrations/uptimerobot.mdx',
+ 'integrations/vanta.mdx',
+ 'integrations/vercel.mdx',
+ 'integrations/wealthbox-service-account.mdx',
+ 'integrations/wealthbox.mdx',
+ 'integrations/webflow-service-account.mdx',
+ 'integrations/webflow.mdx',
+ 'integrations/whatsapp.mdx',
+ 'integrations/wikipedia.mdx',
+ 'integrations/windchill.mdx',
+ 'integrations/wiza.mdx',
+ 'integrations/wordpress.mdx',
+ 'integrations/workday.mdx',
+ 'integrations/x.mdx',
+ 'integrations/youtube.mdx',
+ 'integrations/zendesk.mdx',
+ 'integrations/zep.mdx',
+ 'integrations/zerobounce.mdx',
+ 'integrations/zoho-desk-service-account.mdx',
+ 'integrations/zoho_desk.mdx',
+ 'integrations/zoom-service-account.mdx',
+ 'integrations/zoom.mdx',
+ 'integrations/zoominfo.mdx',
+ 'introduction.mdx',
+ 'keyboard-shortcuts.mdx',
+ 'knowledgebase.mdx',
+ 'knowledgebase/chunking-strategies.mdx',
+ 'knowledgebase/connectors.mdx',
+ 'knowledgebase/debugging-retrieval.mdx',
+ 'knowledgebase/tags.mdx',
+ 'knowledgebase/using-in-workflows.mdx',
+ 'logs-debugging.mdx',
+ 'logs-debugging/alerts.mdx',
+ 'logs-debugging/logging.mdx',
+ 'platform/costs.mdx',
+ 'platform/credentials.mdx',
+ 'platform/enterprise.mdx',
+ 'platform/enterprise/access-control.mdx',
+ 'platform/enterprise/audit-logs.mdx',
+ 'platform/enterprise/custom-blocks.mdx',
+ 'platform/enterprise/data-drains.mdx',
+ 'platform/enterprise/data-retention.mdx',
+ 'platform/enterprise/forks.mdx',
+ 'platform/enterprise/self-hosted.mdx',
+ 'platform/enterprise/session-policies.mdx',
+ 'platform/enterprise/sso.mdx',
+ 'platform/enterprise/verified-domains.mdx',
+ 'platform/enterprise/whitelabeling.mdx',
+ 'platform/organization.mdx',
+ 'platform/permissions.mdx',
+ 'platform/self-hosting.mdx',
+ 'platform/self-hosting/architecture.mdx',
+ 'platform/self-hosting/authentication.mdx',
+ 'platform/self-hosting/background-jobs.mdx',
+ 'platform/self-hosting/docker.mdx',
+ 'platform/self-hosting/email.mdx',
+ 'platform/self-hosting/environment-variables.mdx',
+ 'platform/self-hosting/integrations-oauth.mdx',
+ 'platform/self-hosting/kubernetes.mdx',
+ 'platform/self-hosting/networking.mdx',
+ 'platform/self-hosting/object-storage.mdx',
+ 'platform/self-hosting/observability.mdx',
+ 'platform/self-hosting/platforms.mdx',
+ 'platform/self-hosting/redis.mdx',
+ 'platform/self-hosting/scaling.mdx',
+ 'platform/self-hosting/security.mdx',
+ 'platform/self-hosting/troubleshooting.mdx',
+ 'platform/self-hosting/upgrades.mdx',
+ 'platform/self-hosting/verify.mdx',
+ 'platform/workspaces.mdx',
+ 'quick-reference.mdx',
+ 'tables.mdx',
+ 'tables/using-in-workflows.mdx',
+ 'tables/workflow-columns.mdx',
+ 'workflows.mdx',
+ 'workflows/blocks/agent.mdx',
+ 'workflows/blocks/api.mdx',
+ 'workflows/blocks/condition.mdx',
+ 'workflows/blocks/credential.mdx',
+ 'workflows/blocks/evaluator.mdx',
+ 'workflows/blocks/function.mdx',
+ 'workflows/blocks/guardrails.mdx',
+ 'workflows/blocks/human-in-the-loop.mdx',
+ 'workflows/blocks/logs.mdx',
+ 'workflows/blocks/loop.mdx',
+ 'workflows/blocks/parallel.mdx',
+ 'workflows/blocks/pi.mdx',
+ 'workflows/blocks/response.mdx',
+ 'workflows/blocks/router.mdx',
+ 'workflows/blocks/variables.mdx',
+ 'workflows/blocks/wait.mdx',
+ 'workflows/blocks/webhook.mdx',
+ 'workflows/blocks/workflow.mdx',
+ 'workflows/connections.mdx',
+ 'workflows/data-flow.mdx',
+ 'workflows/deployment.mdx',
+ 'workflows/deployment/agent-events.mdx',
+ 'workflows/deployment/api.mdx',
+ 'workflows/deployment/chat.mdx',
+ 'workflows/deployment/mcp.mdx',
+ 'workflows/how-it-runs.mdx',
+ 'workflows/triggers/rss.mdx',
+ 'workflows/triggers/schedule.mdx',
+ 'workflows/triggers/sim.mdx',
+ 'workflows/triggers/start.mdx',
+ 'workflows/triggers/table.mdx',
+ 'workflows/triggers/webhook.mdx',
+ 'workflows/variables.mdx',
+]
diff --git a/apps/sim/lib/copilot/generated/metrics-v1.ts b/apps/sim/lib/copilot/generated/metrics-v1.ts
index 06c74839c4..b379803f21 100644
--- a/apps/sim/lib/copilot/generated/metrics-v1.ts
+++ b/apps/sim/lib/copilot/generated/metrics-v1.ts
@@ -19,6 +19,7 @@ export const Metric = {
CopilotCacheWrite: 'copilot.cache.write',
CopilotChatBlobBytes: 'copilot.chat.blob.bytes',
CopilotChatBlobCount: 'copilot.chat.blob.count',
+ CopilotDegradedCount: 'copilot.degraded.count',
CopilotFileReadDuration: 'copilot.file.read.duration',
CopilotFileReadSize: 'copilot.file.read.size',
CopilotMessagesSerializeDuration: 'copilot.messages.serialize.duration',
@@ -48,6 +49,7 @@ export const MetricValues: readonly MetricValue[] = [
'copilot.cache.write',
'copilot.chat.blob.bytes',
'copilot.chat.blob.count',
+ 'copilot.degraded.count',
'copilot.file.read.duration',
'copilot.file.read.size',
'copilot.messages.serialize.duration',
diff --git a/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts b/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts
index 76322f7b64..e7440fb3d0 100644
--- a/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts
+++ b/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts
@@ -514,7 +514,13 @@ export const MOTHERSHIP_STREAM_V1_SCHEMA: JsonSchema = {
type: 'object',
},
MothershipStreamV1RunKind: {
- enum: ['checkpoint_pause', 'resumed', 'compaction_start', 'compaction_done'],
+ enum: [
+ 'checkpoint_pause',
+ 'resumed',
+ 'compaction_start',
+ 'compaction_done',
+ 'steering_applied',
+ ],
type: 'string',
},
MothershipStreamV1RunResumedEventEnvelope: {
@@ -777,6 +783,26 @@ export const MOTHERSHIP_STREAM_V1_SCHEMA: JsonSchema = {
enum: ['subagent', 'structured_result', 'subagent_result'],
type: 'string',
},
+ MothershipStreamV1SteeringAppliedPayload: {
+ additionalProperties: false,
+ properties: {
+ content: {
+ type: 'string',
+ },
+ kind: {
+ $ref: '#/$defs/MothershipStreamV1RunKind',
+ },
+ messageId: {
+ type: 'string',
+ },
+ mode: {
+ enum: ['deferred', 'interrupt'],
+ type: 'string',
+ },
+ },
+ required: ['kind', 'messageId', 'content', 'mode'],
+ type: 'object',
+ },
MothershipStreamV1StreamCursor: {
additionalProperties: false,
properties: {
@@ -1313,6 +1339,9 @@ export const MOTHERSHIP_STREAM_V1_SCHEMA: JsonSchema = {
hidden: {
type: 'boolean',
},
+ inbandOwned: {
+ type: 'boolean',
+ },
internal: {
type: 'boolean',
},
diff --git a/apps/sim/lib/copilot/generated/mothership-stream-v1.ts b/apps/sim/lib/copilot/generated/mothership-stream-v1.ts
index 5cdf7801bf..3b47c736f7 100644
--- a/apps/sim/lib/copilot/generated/mothership-stream-v1.ts
+++ b/apps/sim/lib/copilot/generated/mothership-stream-v1.ts
@@ -160,6 +160,7 @@ export interface MothershipStreamV1AdditionalPropertiesMap {
export interface MothershipStreamV1ToolUI {
clientExecutable?: boolean
hidden?: boolean
+ inbandOwned?: boolean
internal?: boolean
}
export interface MothershipStreamV1ToolArgsDeltaEventEnvelope {
@@ -468,12 +469,14 @@ export type MothershipStreamV1RunKind =
| 'resumed'
| 'compaction_start'
| 'compaction_done'
+ | 'steering_applied'
export const MothershipStreamV1RunKind = {
checkpoint_pause: 'checkpoint_pause',
resumed: 'resumed',
compaction_start: 'compaction_start',
compaction_done: 'compaction_done',
+ steering_applied: 'steering_applied',
} as const
export type MothershipStreamV1SessionKind = 'trace' | 'chat' | 'title' | 'start'
diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts
index cd2ca61b5c..ab7556186a 100644
--- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts
+++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts
@@ -7,15 +7,18 @@ export interface ToolCatalogEntry {
clientExecutable?: boolean
hidden?: boolean
id:
- | 'agent'
+ | 'apply_file_edit'
| 'auth'
| 'browser'
| 'browser_click'
+ | 'browser_click_at'
| 'browser_close_tab'
+ | 'browser_drag'
| 'browser_extract'
| 'browser_go_back'
| 'browser_go_forward'
| 'browser_hover'
+ | 'browser_insert_text'
| 'browser_list_sessions'
| 'browser_list_tabs'
| 'browser_navigate'
@@ -23,7 +26,6 @@ export interface ToolCatalogEntry {
| 'browser_open_url'
| 'browser_press_key'
| 'browser_read_text'
- | 'browser_request_takeover'
| 'browser_screenshot'
| 'browser_scroll'
| 'browser_select_option'
@@ -32,26 +34,23 @@ export interface ToolCatalogEntry {
| 'browser_type'
| 'browser_wait_for'
| 'call_integration_tool'
- | 'check_deployment_status'
+ | 'connect_slack_bot'
| 'cp'
- | 'crawl_website'
- | 'create_file'
+ | 'create_empty_file'
| 'create_workflow'
| 'create_workspace_mcp_server'
| 'delete_workspace_mcp_server'
| 'deploy'
- | 'deploy_api'
- | 'deploy_chat'
- | 'deploy_custom_block'
- | 'deploy_mcp'
+ | 'deploy_as_api'
+ | 'deploy_as_chat'
+ | 'deploy_as_mcp'
| 'diff_workflows'
- | 'download_to_workspace_file'
- | 'edit_content'
+ | 'download_file'
| 'edit_workflow'
- | 'enrichment_run'
+ | 'extensions'
+ | 'extract_doc_assets'
| 'ffmpeg'
| 'file'
- | 'function_execute'
| 'generate_api_key'
| 'generate_audio'
| 'generate_image'
@@ -59,34 +58,35 @@ export interface ToolCatalogEntry {
| 'get_block_outputs'
| 'get_block_upstream_references'
| 'get_deployed_workflow_state'
- | 'get_deployment_log'
- | 'get_page_contents'
- | 'get_platform_actions'
+ | 'get_deployment_status'
| 'get_workflow_data'
| 'get_workflow_run_options'
| 'glob'
| 'grep'
+ | 'interrupt_agent'
| 'knowledge'
- | 'knowledge_base'
+ | 'list_deployment_versions'
| 'list_integration_tools'
- | 'list_user_workspaces'
| 'list_workspace_mcp_servers'
| 'load_deployment'
| 'load_integration_tool'
| 'load_skill'
| 'manage_credential'
| 'manage_custom_tool'
- | 'manage_mcp_tool'
+ | 'manage_knowledge_base'
+ | 'manage_mcp_connection'
| 'manage_sandbox'
| 'manage_skill'
- | 'materialize_file'
| 'media'
| 'mkdir'
| 'mv'
| 'oauth_get_auth_link'
| 'oauth_request_access'
| 'open_resource'
+ | 'platform'
+ | 'prepare_file_edit'
| 'promote_to_live'
+ | 'publish_custom_block'
| 'query_logs'
| 'query_user_table'
| 'read'
@@ -97,41 +97,56 @@ export interface ToolCatalogEntry {
| 'run'
| 'run_block'
| 'run_code'
+ | 'run_enrichment'
| 'run_from_block'
+ | 'run_function'
| 'run_workflow'
| 'run_workflow_until_block'
- | 'scrape_page'
+ | 'save_upload'
| 'search'
- | 'search_documentation'
+ | 'search_docs'
| 'search_integration_tools'
| 'search_knowledge_base'
| 'search_library_docs'
- | 'search_online'
- | 'search_patterns'
| 'set_block_enabled'
| 'set_environment_variables'
| 'set_global_workflow_variables'
| 'share_file'
+ | 'steer_agent'
| 'table'
+ | 'table_automations'
+ | 'table_columns'
+ | 'table_enrichments'
+ | 'table_manage'
+ | 'table_rows'
+ | 'table_views'
+ | 'tail_agent'
| 'terminal'
| 'update_deployment_version'
| 'update_workspace_mcp_server'
| 'user_table'
| 'wait'
+ | 'wait_agents'
+ | 'web_crawl'
+ | 'web_fetch'
+ | 'web_scrape'
+ | 'web_search'
| 'workflow'
- | 'workspace_file'
internal?: boolean
mode: 'async' | 'sync'
name:
- | 'agent'
+ | 'apply_file_edit'
| 'auth'
| 'browser'
| 'browser_click'
+ | 'browser_click_at'
| 'browser_close_tab'
+ | 'browser_drag'
| 'browser_extract'
| 'browser_go_back'
| 'browser_go_forward'
| 'browser_hover'
+ | 'browser_insert_text'
| 'browser_list_sessions'
| 'browser_list_tabs'
| 'browser_navigate'
@@ -139,7 +154,6 @@ export interface ToolCatalogEntry {
| 'browser_open_url'
| 'browser_press_key'
| 'browser_read_text'
- | 'browser_request_takeover'
| 'browser_screenshot'
| 'browser_scroll'
| 'browser_select_option'
@@ -148,26 +162,23 @@ export interface ToolCatalogEntry {
| 'browser_type'
| 'browser_wait_for'
| 'call_integration_tool'
- | 'check_deployment_status'
+ | 'connect_slack_bot'
| 'cp'
- | 'crawl_website'
- | 'create_file'
+ | 'create_empty_file'
| 'create_workflow'
| 'create_workspace_mcp_server'
| 'delete_workspace_mcp_server'
| 'deploy'
- | 'deploy_api'
- | 'deploy_chat'
- | 'deploy_custom_block'
- | 'deploy_mcp'
+ | 'deploy_as_api'
+ | 'deploy_as_chat'
+ | 'deploy_as_mcp'
| 'diff_workflows'
- | 'download_to_workspace_file'
- | 'edit_content'
+ | 'download_file'
| 'edit_workflow'
- | 'enrichment_run'
+ | 'extensions'
+ | 'extract_doc_assets'
| 'ffmpeg'
| 'file'
- | 'function_execute'
| 'generate_api_key'
| 'generate_audio'
| 'generate_image'
@@ -175,34 +186,35 @@ export interface ToolCatalogEntry {
| 'get_block_outputs'
| 'get_block_upstream_references'
| 'get_deployed_workflow_state'
- | 'get_deployment_log'
- | 'get_page_contents'
- | 'get_platform_actions'
+ | 'get_deployment_status'
| 'get_workflow_data'
| 'get_workflow_run_options'
| 'glob'
| 'grep'
+ | 'interrupt_agent'
| 'knowledge'
- | 'knowledge_base'
+ | 'list_deployment_versions'
| 'list_integration_tools'
- | 'list_user_workspaces'
| 'list_workspace_mcp_servers'
| 'load_deployment'
| 'load_integration_tool'
| 'load_skill'
| 'manage_credential'
| 'manage_custom_tool'
- | 'manage_mcp_tool'
+ | 'manage_knowledge_base'
+ | 'manage_mcp_connection'
| 'manage_sandbox'
| 'manage_skill'
- | 'materialize_file'
| 'media'
| 'mkdir'
| 'mv'
| 'oauth_get_auth_link'
| 'oauth_request_access'
| 'open_resource'
+ | 'platform'
+ | 'prepare_file_edit'
| 'promote_to_live'
+ | 'publish_custom_block'
| 'query_logs'
| 'query_user_table'
| 'read'
@@ -213,29 +225,41 @@ export interface ToolCatalogEntry {
| 'run'
| 'run_block'
| 'run_code'
+ | 'run_enrichment'
| 'run_from_block'
+ | 'run_function'
| 'run_workflow'
| 'run_workflow_until_block'
- | 'scrape_page'
+ | 'save_upload'
| 'search'
- | 'search_documentation'
+ | 'search_docs'
| 'search_integration_tools'
| 'search_knowledge_base'
| 'search_library_docs'
- | 'search_online'
- | 'search_patterns'
| 'set_block_enabled'
| 'set_environment_variables'
| 'set_global_workflow_variables'
| 'share_file'
+ | 'steer_agent'
| 'table'
+ | 'table_automations'
+ | 'table_columns'
+ | 'table_enrichments'
+ | 'table_manage'
+ | 'table_rows'
+ | 'table_views'
+ | 'tail_agent'
| 'terminal'
| 'update_deployment_version'
| 'update_workspace_mcp_server'
| 'user_table'
| 'wait'
+ | 'wait_agents'
+ | 'web_crawl'
+ | 'web_fetch'
+ | 'web_scrape'
+ | 'web_search'
| 'workflow'
- | 'workspace_file'
parameters: unknown
requiredPermission?: 'admin' | 'write'
requiresApproval?: boolean
@@ -249,26 +273,42 @@ export interface ToolCatalogEntry {
| 'file'
| 'knowledge'
| 'media'
+ | 'platform'
| 'run'
| 'search'
| 'table'
| 'workflow'
}
-export const Agent: ToolCatalogEntry = {
- id: 'agent',
- name: 'agent',
- route: 'subagent',
+export const ApplyFileEdit: ToolCatalogEntry = {
+ id: 'apply_file_edit',
+ name: 'apply_file_edit',
+ route: 'sim',
mode: 'async',
parameters: {
- properties: {
- request: { description: 'What tool/skill/MCP action is needed.', type: 'string' },
- },
- required: ['request'],
type: 'object',
+ properties: {
+ content: {
+ type: 'string',
+ description:
+ 'The text content to write. For append: text to append. For update: full replacement text. For patch with search_replace: the replacement text. For patch with anchored: the insert/replacement text.',
+ },
+ },
+ required: ['content'],
+ },
+ resultSchema: {
+ type: 'object',
+ properties: {
+ data: {
+ type: 'object',
+ description:
+ 'Optional operation metadata such as file id, file name, size, and content type.',
+ },
+ message: { type: 'string', description: 'Human-readable summary of the outcome.' },
+ success: { type: 'boolean', description: 'Whether the content was applied successfully.' },
+ },
+ required: ['success', 'message'],
},
- subagentId: 'agent',
- internal: true,
requiredPermission: 'write',
}
@@ -425,6 +465,135 @@ export const BrowserClick: ToolCatalogEntry = {
clientExecutable: true,
}
+export const BrowserClickAt: ToolCatalogEntry = {
+ id: 'browser_click_at',
+ name: 'browser_click_at',
+ route: 'client',
+ mode: 'async',
+ parameters: {
+ type: 'object',
+ properties: {
+ clickCount: {
+ type: 'number',
+ description: '1 = single click (default), 2 = double-click, 3 = triple-click.',
+ },
+ x: {
+ type: 'number',
+ description:
+ "X in CSS pixels within the current viewport. When read off a browser_screenshot, divide the image pixel value by the screenshot's scale.",
+ },
+ y: {
+ type: 'number',
+ description:
+ 'Y in CSS pixels within the current viewport, converted from screenshot pixels the same way as x.',
+ },
+ },
+ required: ['x', 'y'],
+ },
+ resultSchema: {
+ type: 'object',
+ properties: {
+ activeTab: {
+ type: 'object',
+ description: 'New active tab after a tab-changing click.',
+ properties: {
+ tabId: { type: 'string', description: 'Stable browser tab id.' },
+ url: { type: 'string', description: 'New active tab URL.' },
+ },
+ },
+ clickCount: {
+ type: 'number',
+ description: 'The click count that was dispatched (1, 2, or 3).',
+ },
+ clickedAt: {
+ type: 'object',
+ description: 'The CSS-pixel viewport point that was clicked.',
+ properties: {
+ x: { type: 'number', description: 'Clicked X in CSS viewport pixels.' },
+ y: { type: 'number', description: 'Clicked Y in CSS viewport pixels.' },
+ },
+ },
+ dialogs: {
+ type: 'array',
+ description: 'Visible DOM dialogs remaining after the click.',
+ items: { type: 'string' },
+ },
+ dispatched: {
+ type: 'boolean',
+ description: 'Whether the native pointer click was dispatched at the point.',
+ },
+ effect: {
+ type: 'object',
+ description:
+ 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.',
+ properties: {
+ dialogChanged: { type: 'boolean', description: 'The visible DOM dialog set changed.' },
+ domChanged: {
+ type: 'boolean',
+ description: 'The DOM mutation revision changed; weak evidence on its own.',
+ },
+ fieldChanged: {
+ type: 'boolean',
+ description: 'The safely inspectable focused-field state changed.',
+ },
+ focusChanged: { type: 'boolean', description: 'The focused element changed.' },
+ popupChanged: { type: 'boolean', description: 'The visible popup/menu set changed.' },
+ scrollChanged: {
+ type: 'boolean',
+ description: 'A tracked scroll offset changed; weak evidence except for scroll keys.',
+ },
+ tabChanged: { type: 'boolean', description: 'The active browser tab changed.' },
+ targetChanged: {
+ type: 'boolean',
+ description: "The requested target's checked/selected/expanded/open state changed.",
+ },
+ titleChanged: {
+ type: 'boolean',
+ description: 'The document title changed; weak evidence on its own.',
+ },
+ urlChanged: { type: 'boolean', description: 'The observed URL changed.' },
+ },
+ },
+ effectObserved: {
+ type: 'boolean',
+ description:
+ 'Whether a strong page change (URL/dialog/popup/target, or focus into an editable) followed the click.',
+ },
+ note: {
+ type: 'string',
+ description: 'Caution or follow-up guidance, present when the click needs verification.',
+ },
+ notices: {
+ type: 'array',
+ description:
+ 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.',
+ items: { type: 'string' },
+ },
+ possibleEffectObserved: {
+ type: 'boolean',
+ description: 'Whether only weaker background churn (DOM/title) was observed.',
+ },
+ target: {
+ type: 'string',
+ description:
+ 'What the point resolved to before the click (tag/role and accessible name) — confirm it matches the intended target.',
+ },
+ targetCursor: {
+ type: 'string',
+ description:
+ "The CSS cursor at the point (e.g. 'pointer', 'text', 'crosshair') — a hint about what kind of surface was hit.",
+ },
+ trusted: {
+ type: 'boolean',
+ description:
+ 'True — coordinate and drag input always use the trusted Chromium pointer pipeline.',
+ },
+ },
+ required: ['dispatched'],
+ },
+ clientExecutable: true,
+}
+
export const BrowserCloseTab: ToolCatalogEntry = {
id: 'browser_close_tab',
name: 'browser_close_tab',
@@ -443,6 +612,130 @@ export const BrowserCloseTab: ToolCatalogEntry = {
clientExecutable: true,
}
+export const BrowserDrag: ToolCatalogEntry = {
+ id: 'browser_drag',
+ name: 'browser_drag',
+ route: 'client',
+ mode: 'async',
+ parameters: {
+ type: 'object',
+ properties: {
+ fromElementId: {
+ type: 'number',
+ description: 'Drag source element id from the latest snapshot. Alternative to fromX/fromY.',
+ },
+ fromX: {
+ type: 'number',
+ description:
+ 'Drag source X in CSS viewport pixels (paired with fromY) when no source element id is available.',
+ },
+ fromY: { type: 'number', description: 'Drag source Y in CSS viewport pixels.' },
+ toElementId: {
+ type: 'number',
+ description: 'Drop target element id from the latest snapshot. Alternative to toX/toY.',
+ },
+ toX: {
+ type: 'number',
+ description: 'Drop target X in CSS viewport pixels (paired with toY).',
+ },
+ toY: { type: 'number', description: 'Drop target Y in CSS viewport pixels.' },
+ },
+ },
+ resultSchema: {
+ type: 'object',
+ properties: {
+ dialogs: {
+ type: 'array',
+ description: 'Visible DOM dialogs remaining after the click.',
+ items: { type: 'string' },
+ },
+ dispatched: { type: 'boolean', description: 'Whether the full drag gesture was dispatched.' },
+ effect: {
+ type: 'object',
+ description:
+ 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.',
+ properties: {
+ dialogChanged: { type: 'boolean', description: 'The visible DOM dialog set changed.' },
+ domChanged: {
+ type: 'boolean',
+ description: 'The DOM mutation revision changed; weak evidence on its own.',
+ },
+ fieldChanged: {
+ type: 'boolean',
+ description: 'The safely inspectable focused-field state changed.',
+ },
+ focusChanged: { type: 'boolean', description: 'The focused element changed.' },
+ popupChanged: { type: 'boolean', description: 'The visible popup/menu set changed.' },
+ scrollChanged: {
+ type: 'boolean',
+ description: 'A tracked scroll offset changed; weak evidence except for scroll keys.',
+ },
+ tabChanged: { type: 'boolean', description: 'The active browser tab changed.' },
+ targetChanged: {
+ type: 'boolean',
+ description: "The requested target's checked/selected/expanded/open state changed.",
+ },
+ titleChanged: {
+ type: 'boolean',
+ description: 'The document title changed; weak evidence on its own.',
+ },
+ urlChanged: { type: 'boolean', description: 'The observed URL changed.' },
+ },
+ },
+ effectObserved: {
+ type: 'boolean',
+ description:
+ 'Whether an observable page change (DOM/URL/dialog/target/scroll) followed the drag.',
+ },
+ from: {
+ type: 'object',
+ description: 'The resolved drag source.',
+ properties: {
+ element: { type: 'string', description: 'What that endpoint resolved to, when known.' },
+ x: { type: 'number', description: 'Endpoint X in CSS viewport pixels.' },
+ y: { type: 'number', description: 'Endpoint Y in CSS viewport pixels.' },
+ },
+ },
+ nativeHtml5Drag: {
+ type: 'boolean',
+ description:
+ 'True when the page started a native HTML5 drag and it was completed as a real drag-and-drop; false for a pointer-sensor drag.',
+ },
+ note: {
+ type: 'string',
+ description:
+ 'Caution or follow-up guidance — e.g. verify the drop with a fresh snapshot when no effect was observed.',
+ },
+ notices: {
+ type: 'array',
+ description:
+ 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.',
+ items: { type: 'string' },
+ },
+ possibleEffectObserved: {
+ type: 'boolean',
+ description: 'Whether only weaker background churn (DOM/title) was observed.',
+ },
+ to: {
+ type: 'object',
+ description: 'The resolved drop target.',
+ properties: {
+ element: { type: 'string', description: 'What that endpoint resolved to, when known.' },
+ x: { type: 'number', description: 'Endpoint X in CSS viewport pixels.' },
+ y: { type: 'number', description: 'Endpoint Y in CSS viewport pixels.' },
+ },
+ },
+ trusted: {
+ type: 'boolean',
+ description:
+ 'True — coordinate and drag input always use the trusted Chromium pointer pipeline.',
+ },
+ },
+ required: ['dispatched'],
+ },
+ clientExecutable: true,
+}
+
export const BrowserExtract: ToolCatalogEntry = {
id: 'browser_extract',
name: 'browser_extract',
@@ -605,6 +898,116 @@ export const BrowserHover: ToolCatalogEntry = {
clientExecutable: true,
}
+export const BrowserInsertText: ToolCatalogEntry = {
+ id: 'browser_insert_text',
+ name: 'browser_insert_text',
+ route: 'client',
+ mode: 'async',
+ parameters: {
+ type: 'object',
+ properties: {
+ submit: { type: 'boolean', description: 'Press Enter after inserting. Default false.' },
+ text: { type: 'string', description: 'The text to insert at the caret. Must be non-empty.' },
+ },
+ required: ['text'],
+ },
+ resultSchema: {
+ type: 'object',
+ properties: {
+ activeElement: { type: 'string', description: 'Focused element kind after the action.' },
+ dispatched: {
+ type: 'boolean',
+ description: 'Whether the text was inserted through the native IME pipeline.',
+ },
+ effect: {
+ type: 'object',
+ description:
+ 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.',
+ properties: {
+ dialogChanged: { type: 'boolean', description: 'The visible DOM dialog set changed.' },
+ domChanged: {
+ type: 'boolean',
+ description: 'The DOM mutation revision changed; weak evidence on its own.',
+ },
+ fieldChanged: {
+ type: 'boolean',
+ description: 'The safely inspectable focused-field state changed.',
+ },
+ focusChanged: { type: 'boolean', description: 'The focused element changed.' },
+ popupChanged: { type: 'boolean', description: 'The visible popup/menu set changed.' },
+ scrollChanged: {
+ type: 'boolean',
+ description: 'A tracked scroll offset changed; weak evidence except for scroll keys.',
+ },
+ tabChanged: { type: 'boolean', description: 'The active browser tab changed.' },
+ targetChanged: {
+ type: 'boolean',
+ description: "The requested target's checked/selected/expanded/open state changed.",
+ },
+ titleChanged: {
+ type: 'boolean',
+ description: 'The document title changed; weak evidence on its own.',
+ },
+ urlChanged: { type: 'boolean', description: 'The observed URL changed.' },
+ },
+ },
+ effectObserved: {
+ type: 'boolean',
+ description:
+ 'Whether a field or page change confirmed the insertion. Canvas editors cannot echo one — verify visually.',
+ },
+ insertedChars: { type: 'number', description: 'How many characters were inserted.' },
+ kind: {
+ type: 'string',
+ description:
+ 'The kind of focused editable that received the text (input:*, textarea, contenteditable, canvas, textbox-role).',
+ },
+ note: {
+ type: 'string',
+ description:
+ 'Caution or follow-up guidance, e.g. that a canvas editor needs visual verification.',
+ },
+ notices: {
+ type: 'array',
+ description:
+ 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.',
+ items: { type: 'string' },
+ },
+ possibleEffectObserved: {
+ type: 'boolean',
+ description: 'Whether only weaker background churn was observed.',
+ },
+ redacted: {
+ type: 'boolean',
+ description: 'Whether sensitive focused-field details were withheld.',
+ },
+ selectedChars: {
+ type: 'number',
+ description: 'Number of selected characters when safely inspectable.',
+ },
+ submitDispatched: { type: 'boolean', description: 'Whether Enter was actually dispatched.' },
+ submitRequested: {
+ type: 'boolean',
+ description: 'Whether Enter was requested after insertion.',
+ },
+ trusted: {
+ type: 'boolean',
+ description: 'True — insertion uses the trusted input pipeline.',
+ },
+ valueLength: {
+ type: 'number',
+ description: 'Focused non-secret field length when safely inspectable.',
+ },
+ valuePreview: {
+ type: 'string',
+ description: 'Bounded focused non-secret field preview when safely inspectable.',
+ },
+ },
+ required: ['dispatched'],
+ },
+ clientExecutable: true,
+}
+
export const BrowserListSessions: ToolCatalogEntry = {
id: 'browser_list_sessions',
name: 'browser_list_sessions',
@@ -820,31 +1223,6 @@ export const BrowserReadText: ToolCatalogEntry = {
clientExecutable: true,
}
-export const BrowserRequestTakeover: ToolCatalogEntry = {
- id: 'browser_request_takeover',
- name: 'browser_request_takeover',
- route: 'client',
- mode: 'async',
- parameters: {
- type: 'object',
- properties: {
- purpose: {
- type: 'string',
- description:
- 'Why takeover is needed. Set sign_in for a login/password flow so the desktop can remember a privacy-preserving session hint after the user finishes.',
- enum: ['sign_in', 'captcha', 'payment', 'sensitive_confirmation', 'other'],
- },
- reason: {
- type: 'string',
- description:
- "Short explanation shown to the user of what they need to do (e.g. 'Sign in to Notion').",
- },
- },
- required: ['reason'],
- },
- clientExecutable: true,
-}
-
export const BrowserScreenshot: ToolCatalogEntry = {
id: 'browser_screenshot',
name: 'browser_screenshot',
@@ -1234,20 +1612,34 @@ export const CallIntegrationTool: ToolCatalogEntry = {
requiresApproval: true,
}
-export const CheckDeploymentStatus: ToolCatalogEntry = {
- id: 'check_deployment_status',
- name: 'check_deployment_status',
+export const ConnectSlackBot: ToolCatalogEntry = {
+ id: 'connect_slack_bot',
+ name: 'connect_slack_bot',
route: 'sim',
mode: 'async',
parameters: {
type: 'object',
properties: {
- workflowId: {
+ botTokenEnvVar: {
type: 'string',
- description: 'Workflow ID to check (defaults to current workflow)',
+ description:
+ 'NAME of the environment variable holding the bot token (xoxb-..., OAuth & Permissions → Bot User OAuth Token). Pass the variable name, never the token value.',
+ },
+ description: { type: 'string', description: 'Optional description shown on the credential.' },
+ displayName: {
+ type: 'string',
+ description:
+ 'Display name for the credential, shown in the credential picker (e.g. "Elder Bot"). Must be unique in the workspace.',
+ },
+ signingSecretEnvVar: {
+ type: 'string',
+ description:
+ "NAME of the environment variable holding the Slack app's signing secret (Basic Information → App Credentials). Pass the variable name, never the secret value.",
},
},
+ required: ['displayName', 'signingSecretEnvVar', 'botTokenEnvVar'],
},
+ requiredPermission: 'write',
}
export const Cp: ToolCatalogEntry = {
@@ -1280,35 +1672,9 @@ export const Cp: ToolCatalogEntry = {
requiredPermission: 'write',
}
-export const CrawlWebsite: ToolCatalogEntry = {
- id: 'crawl_website',
- name: 'crawl_website',
- route: 'go',
- mode: 'sync',
- parameters: {
- type: 'object',
- properties: {
- exclude_paths: {
- type: 'array',
- description: 'Skip URLs matching these patterns',
- items: { type: 'string' },
- },
- include_paths: {
- type: 'array',
- description: 'Only crawl URLs matching these patterns',
- items: { type: 'string' },
- },
- limit: { type: 'number', description: 'Maximum pages to crawl (default 10, max 50)' },
- max_depth: { type: 'number', description: 'How deep to follow links (default 2)' },
- url: { type: 'string', description: 'Starting URL to crawl from' },
- },
- required: ['url'],
- },
-}
-
-export const CreateFile: ToolCatalogEntry = {
- id: 'create_file',
- name: 'create_file',
+export const CreateEmptyFile: ToolCatalogEntry = {
+ id: 'create_empty_file',
+ name: 'create_empty_file',
route: 'sim',
mode: 'async',
parameters: {
@@ -1460,9 +1826,9 @@ export const Deploy: ToolCatalogEntry = {
internal: true,
}
-export const DeployApi: ToolCatalogEntry = {
- id: 'deploy_api',
- name: 'deploy_api',
+export const DeployAsApi: ToolCatalogEntry = {
+ id: 'deploy_as_api',
+ name: 'deploy_as_api',
route: 'sim',
mode: 'async',
parameters: {
@@ -1511,7 +1877,7 @@ export const DeployApi: ToolCatalogEntry = {
deploymentType: {
type: 'string',
description:
- 'Deployment surface this result describes. For deploy_api and redeploy this is always "api".',
+ 'Deployment surface this result describes. For deploy_as_api and redeploy this is always "api".',
},
examples: {
type: 'object',
@@ -1541,9 +1907,9 @@ export const DeployApi: ToolCatalogEntry = {
requiresApproval: true,
}
-export const DeployChat: ToolCatalogEntry = {
- id: 'deploy_chat',
- name: 'deploy_chat',
+export const DeployAsChat: ToolCatalogEntry = {
+ id: 'deploy_as_chat',
+ name: 'deploy_as_chat',
route: 'sim',
mode: 'async',
parameters: {
@@ -1642,7 +2008,7 @@ export const DeployChat: ToolCatalogEntry = {
deploymentType: {
type: 'string',
description:
- 'Deployment surface this result describes. For deploy_chat this is always "chat".',
+ 'Deployment surface this result describes. For deploy_as_chat this is always "chat".',
},
examples: {
type: 'object',
@@ -1660,7 +2026,7 @@ export const DeployChat: ToolCatalogEntry = {
},
success: {
type: 'boolean',
- description: 'Whether the deploy_chat action completed successfully.',
+ description: 'Whether the deploy_as_chat action completed successfully.',
},
version: {
type: 'number',
@@ -1687,121 +2053,9 @@ export const DeployChat: ToolCatalogEntry = {
requiresApproval: true,
}
-export const DeployCustomBlock: ToolCatalogEntry = {
- id: 'deploy_custom_block',
- name: 'deploy_custom_block',
- route: 'sim',
- mode: 'async',
- parameters: {
- type: 'object',
- properties: {
- action: {
- type: 'string',
- description: 'Whether to publish (deploy) or unpublish (undeploy) the custom block',
- enum: ['deploy', 'undeploy'],
- default: 'deploy',
- },
- description: {
- type: 'string',
- description: 'Short description shown in the block picker, max 280 characters',
- },
- exposedOutputs: {
- type: 'array',
- description:
- "Outputs the block exposes, each mapping a child block output path to a friendly name (use get_block_outputs for valid paths). Omit to expose the terminal block's whole result",
- items: {
- type: 'object',
- properties: {
- blockId: { type: 'string', description: 'Block UUID inside the workflow' },
- name: { type: 'string', description: 'Friendly output name shown on the block' },
- path: {
- type: 'string',
- description:
- "Dot-path into that block's output (from get_block_outputs relativeOutputs)",
- },
- },
- required: ['blockId', 'path', 'name'],
- },
- },
- iconUrl: {
- type: 'string',
- description:
- 'Optional icon image for the block: a workspace file VFS path (e.g. "files/icon.png", copied into public icon storage at publish) or an https image URL. Omit to use the organization\'s default icon',
- },
- inputs: {
- type: 'array',
- description:
- "Optional per-input placeholder overrides. Input names and types are derived from the workflow's input trigger and cannot be changed here",
- items: {
- type: 'object',
- properties: {
- id: { type: 'string', description: 'Stable id of the input trigger field' },
- placeholder: {
- type: 'string',
- description: "Placeholder text shown in the block's input field",
- },
- },
- required: ['id'],
- },
- },
- name: {
- type: 'string',
- description:
- 'Display name for the block, max 60 characters. REQUIRED the first time a workflow is published. When republishing an existing block, omit it to keep the current name or pass a new one to rename. Ignored for undeploy.',
- },
- workflowId: { type: 'string', description: 'Workflow ID (defaults to active workflow)' },
- },
- },
- resultSchema: {
- type: 'object',
- properties: {
- action: {
- type: 'string',
- description: 'Action performed by the tool, such as "deploy" or "undeploy".',
- },
- blockId: { type: 'string', description: 'Custom block record ID.' },
- blockType: {
- type: 'string',
- description: 'Stable block type slug (custom_block_*) used in workflow state.',
- },
- deploymentConfig: {
- type: 'object',
- description:
- "Structured deployment configuration keyed by surface name. Includes the block's type, name, description, icon, derived input fields, and exposed outputs.",
- },
- deploymentStatus: {
- type: 'object',
- description:
- 'Structured per-surface deployment status keyed by surface name, including customBlock and the underlying api surface when applicable.',
- },
- deploymentType: {
- type: 'string',
- description:
- 'Deployment surface this result describes. For deploy_custom_block this is always "custom_block".',
- },
- isDeployed: {
- type: 'boolean',
- description: 'Whether the custom block is published after this tool call.',
- },
- name: { type: 'string', description: 'Display name of the custom block.' },
- removed: {
- type: 'boolean',
- description: 'Whether the custom block was unpublished during an undeploy action.',
- },
- updated: {
- type: 'boolean',
- description: 'Whether an existing custom block was updated instead of created.',
- },
- workflowId: { type: 'string', description: 'Workflow ID the custom block is bound to.' },
- },
- required: ['deploymentType', 'deploymentStatus'],
- },
- requiredPermission: 'admin',
-}
-
-export const DeployMcp: ToolCatalogEntry = {
- id: 'deploy_mcp',
- name: 'deploy_mcp',
+export const DeployAsMcp: ToolCatalogEntry = {
+ id: 'deploy_as_mcp',
+ name: 'deploy_as_mcp',
route: 'sim',
mode: 'async',
parameters: {
@@ -1863,7 +2117,7 @@ export const DeployMcp: ToolCatalogEntry = {
deploymentType: {
type: 'string',
description:
- 'Deployment surface this result describes. For deploy_mcp this is always "mcp".',
+ 'Deployment surface this result describes. For deploy_as_mcp this is always "mcp".',
},
examples: {
type: 'object',
@@ -1925,9 +2179,9 @@ export const DiffWorkflows: ToolCatalogEntry = {
},
}
-export const DownloadToWorkspaceFile: ToolCatalogEntry = {
- id: 'download_to_workspace_file',
- name: 'download_to_workspace_file',
+export const DownloadFile: ToolCatalogEntry = {
+ id: 'download_file',
+ name: 'download_file',
route: 'sim',
mode: 'async',
parameters: {
@@ -1980,38 +2234,6 @@ export const DownloadToWorkspaceFile: ToolCatalogEntry = {
capabilities: ['file_output'],
}
-export const EditContent: ToolCatalogEntry = {
- id: 'edit_content',
- name: 'edit_content',
- route: 'sim',
- mode: 'async',
- parameters: {
- type: 'object',
- properties: {
- content: {
- type: 'string',
- description:
- 'The text content to write. For append: text to append. For update: full replacement text. For patch with search_replace: the replacement text. For patch with anchored: the insert/replacement text.',
- },
- },
- required: ['content'],
- },
- resultSchema: {
- type: 'object',
- properties: {
- data: {
- type: 'object',
- description:
- 'Optional operation metadata such as file id, file name, size, and content type.',
- },
- message: { type: 'string', description: 'Human-readable summary of the outcome.' },
- success: { type: 'boolean', description: 'Whether the content was applied successfully.' },
- },
- required: ['success', 'message'],
- },
- requiredPermission: 'write',
-}
-
export const EditWorkflow: ToolCatalogEntry = {
id: 'edit_workflow',
name: 'edit_workflow',
@@ -2039,7 +2261,7 @@ export const EditWorkflow: ToolCatalogEntry = {
params: {
type: 'object',
description:
- 'Parameters for the operation (optional).\nFor edit: {"inputs": {"temperature": 0.5}} NOT {"subBlocks": {"temperature": {"value": 0.5}}}\nFor add: {"type": "agent", "name": "My Agent", "inputs": {"model": ""}}\nFor delete: omit params entirely (none needed)',
+ 'Parameters for the operation (optional).\nFor edit: {"inputs": {"temperature": 0.5}} NOT {"subBlocks": {"temperature": {"value": 0.5}}}\nFor add: {"type": "agent", "name": "My Agent", "inputs": {"model": ""}}\nFor delete: omit params entirely (none needed)\nBlock-level settings (retry, triggerMode, advancedMode) go beside "inputs", never inside it.',
},
},
required: ['operation_type', 'block_id'],
@@ -2056,54 +2278,57 @@ export const EditWorkflow: ToolCatalogEntry = {
requiredPermission: 'write',
}
-export const EnrichmentRun: ToolCatalogEntry = {
- id: 'enrichment_run',
- name: 'enrichment_run',
+export const Extensions: ToolCatalogEntry = {
+ id: 'extensions',
+ name: 'extensions',
+ route: 'subagent',
+ mode: 'async',
+ parameters: {
+ properties: {
+ request: { description: 'What tool/skill/MCP action is needed.', type: 'string' },
+ sessionId: {
+ description:
+ 'Reusable session ID returned by an earlier extensions call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message. Omit it for a new or independent task.',
+ type: 'string',
+ },
+ title: {
+ description:
+ "Required private orchestration label (3–8 words) for this session's stable objective. Stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator; not shown to the extensions agent. When resuming with sessionId, copy the registry title unchanged.",
+ maxLength: 120,
+ type: 'string',
+ },
+ },
+ required: ['request', 'title'],
+ type: 'object',
+ },
+ subagentId: 'agent',
+ internal: true,
+ requiredPermission: 'write',
+}
+
+export const ExtractDocAssets: ToolCatalogEntry = {
+ id: 'extract_doc_assets',
+ name: 'extract_doc_assets',
route: 'sim',
mode: 'async',
parameters: {
type: 'object',
properties: {
- enrichmentId: {
+ destination: {
type: 'string',
description:
- "Which enrichment to run. Discover the full set and each one's inputs/outputs via user_table.list_enrichments.",
- enum: [
- 'work-email',
- 'phone-number',
- 'company-domain',
- 'company-info',
- 'email-verification',
- ],
+ 'Folder to write the extracted set into. Defaults to a "files/ assets" folder next to the source.',
},
- inputs: {
- type: 'object',
+ path: {
+ type: 'string',
description:
- 'Map of the enrichment\'s input id → value, e.g. { "fullName": "Jane Doe", "companyDomain": "acme.com" }. Provide a value for every required input.',
+ 'Workspace VFS path of the source document, e.g. files/Brand%20Deck.pptx. Must be an existing .pptx, .docx, or .pdf file.',
},
},
- required: ['enrichmentId', 'inputs'],
- },
- resultSchema: {
- type: 'object',
- properties: {
- matched: {
- type: 'boolean',
- description: 'True when a provider returned a non-empty result.',
- },
- provider: {
- type: ['string', 'null'],
- description:
- 'Internal label of the provider that produced the result (billing/diagnostics only — do NOT surface it to the user), or null on no match.',
- },
- result: {
- type: 'object',
- description: 'Mapped output values from the winning provider (empty object on no match).',
- },
- },
- required: ['matched', 'result'],
+ required: ['path'],
},
requiredPermission: 'write',
+ capabilities: ['file_output'],
}
export const Ffmpeg: ToolCatalogEntry = {
@@ -2285,164 +2510,25 @@ export const File: ToolCatalogEntry = {
"Optional brief instruction (one short sentence) to scope the task. The agent inherits the full conversation history — do NOT restate or rewrite conversation content, only add scoping the history doesn't convey.",
type: 'string',
},
+ sessionId: {
+ description:
+ 'Reusable session ID returned by an earlier file call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message — the agent resumes from its saved transcript and receives unseen parent conversation messages. Omit it for a new or independent task.',
+ type: 'string',
+ },
+ title: {
+ description:
+ "Required private orchestration label (3–8 words) for this session's stable objective. Stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator; not shown to the file agent. When resuming with sessionId, copy the registry title unchanged.",
+ maxLength: 120,
+ type: 'string',
+ },
},
+ required: ['title'],
type: 'object',
},
subagentId: 'file',
internal: true,
}
-export const FunctionExecute: ToolCatalogEntry = {
- id: 'function_execute',
- name: 'function_execute',
- route: 'sim',
- mode: 'async',
- parameters: {
- type: 'object',
- properties: {
- code: {
- type: 'string',
- description:
- 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Use each needed secret as {{VAR_NAME}}; the reference resolves to the value exactly as stored.',
- },
- inputs: {
- type: 'object',
- description:
- 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.',
- properties: {
- directories: {
- type: 'array',
- description:
- 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.',
- items: {
- type: 'object',
- properties: {
- path: {
- type: 'string',
- description:
- 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/{path}".',
- },
- sandboxPath: {
- type: 'string',
- description:
- 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.',
- },
- },
- required: ['path'],
- },
- },
- files: {
- type: 'array',
- description: 'Workspace files to mount into the sandbox.',
- items: {
- type: 'object',
- properties: {
- path: {
- type: 'string',
- description:
- 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".',
- },
- sandboxPath: {
- type: 'string',
- description:
- 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.',
- },
- },
- required: ['path'],
- },
- },
- tables: {
- type: 'array',
- description: 'Workspace tables to mount as CSV files.',
- items: {
- type: 'object',
- properties: {
- path: { type: 'string', description: 'Canonical VFS table path when available.' },
- sandboxPath: {
- type: 'string',
- description: 'Optional full sandbox path for the mounted CSV.',
- },
- tableId: { type: 'string', description: 'Workspace table ID.' },
- },
- },
- },
- },
- },
- language: {
- type: 'string',
- description: 'Execution language.',
- enum: ['javascript', 'python', 'shell'],
- },
- outputTable: {
- type: 'string',
- description:
- 'Table ID to overwrite with the code\'s return value. Code MUST return an array of objects where keys match column names. All existing rows are replaced. Example: "tbl_abc123"',
- },
- outputs: {
- type: 'object',
- description:
- 'Workspace files to create or overwrite from returned code results or sandbox-created files.',
- properties: {
- files: {
- type: 'array',
- description:
- 'File outputs. Missing parent folders are created automatically for create mode.',
- items: {
- type: 'object',
- properties: {
- format: {
- type: 'string',
- description: 'Optional serialization format for returned values.',
- enum: ['json', 'csv', 'txt', 'md', 'html'],
- },
- mimeType: {
- type: 'string',
- description: 'Optional MIME type override when inference is not enough.',
- },
- mode: {
- type: 'string',
- description: 'Create a new file or overwrite an existing file at path.',
- enum: ['create', 'overwrite'],
- },
- path: {
- type: 'string',
- description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".',
- },
- sandboxPath: {
- type: 'string',
- description:
- 'Optional full path to a file created inside the sandbox. Omit to save the code return value.',
- },
- },
- required: ['path', 'mode'],
- },
- },
- },
- },
- sandboxId: {
- type: 'string',
- description:
- 'Optional Sim sandbox id from agent/sandboxes/{name}.json. DEFAULT-FIRST: omit this whenever the documented default function_execute environment can do the job. Select a ready existing Sim sandbox only when a required third-party dependency, Debian system package, or managed CLI is known to be absent, or a default attempt failed specifically because it was missing. Never guess an id.',
- },
- timeout: {
- type: 'number',
- description:
- 'Maximum execution time in SECONDS (Sim converts to milliseconds). The sandbox stops execution and returns a timeout error after this duration. Defaults to 10 seconds and is capped at 300 seconds regardless of plan.',
- default: 10,
- },
- title: {
- type: 'string',
- description:
- 'Short user-visible label for this execution, e.g. "Clean customer CSV", "Revenue chart", or "Query GitHub issues".',
- },
- },
- required: ['code'],
- },
- requiredPermission: 'write',
- requiresApproval: true,
- capabilities: ['file_input', 'directory_input', 'file_output', 'table_input', 'table_output'],
-}
-
export const GenerateApiKey: ToolCatalogEntry = {
id: 'generate_api_key',
name: 'generate_api_key',
@@ -2973,9 +3059,9 @@ export const GetDeployedWorkflowState: ToolCatalogEntry = {
},
}
-export const GetDeploymentLog: ToolCatalogEntry = {
- id: 'get_deployment_log',
- name: 'get_deployment_log',
+export const GetDeploymentStatus: ToolCatalogEntry = {
+ id: 'get_deployment_status',
+ name: 'get_deployment_status',
route: 'sim',
mode: 'async',
parameters: {
@@ -2983,47 +3069,12 @@ export const GetDeploymentLog: ToolCatalogEntry = {
properties: {
workflowId: {
type: 'string',
- description: 'Optional workflow ID. If not provided, uses the current workflow in context.',
+ description: 'Workflow ID to check (defaults to current workflow)',
},
},
},
}
-export const GetPageContents: ToolCatalogEntry = {
- id: 'get_page_contents',
- name: 'get_page_contents',
- route: 'go',
- mode: 'sync',
- parameters: {
- type: 'object',
- properties: {
- include_highlights: {
- type: 'boolean',
- description: 'Include key highlights (default false)',
- },
- include_summary: {
- type: 'boolean',
- description: 'Include AI-generated summary (default false)',
- },
- include_text: { type: 'boolean', description: 'Include full page text (default true)' },
- urls: {
- type: 'array',
- description: 'URLs to get content from (max 10)',
- items: { type: 'string' },
- },
- },
- required: ['urls'],
- },
-}
-
-export const GetPlatformActions: ToolCatalogEntry = {
- id: 'get_platform_actions',
- name: 'get_platform_actions',
- route: 'sim',
- mode: 'async',
- parameters: { type: 'object', properties: {} },
-}
-
export const GetWorkflowData: ToolCatalogEntry = {
id: 'get_workflow_data',
name: 'get_workflow_data',
@@ -3117,12 +3168,12 @@ export const Grep: ToolCatalogEntry = {
path: {
type: 'string',
description:
- "Optional scope. A prefix (e.g. 'workflows/', 'environment/', 'internal/') searches the VFS map under it. An exact single-file path under files/ or uploads/ (optionally with /content) searches that file's content only; folders and multi-file trees are rejected for content search.",
+ "Optional scope. A prefix (e.g. 'workflows/', 'environment/', 'internal/') searches the VFS map under it. An exact supported single-file path searches that file's content; folders and multi-file trees are rejected for content search.",
},
pattern: {
type: 'string',
description:
- "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, memories) by default; searches a single file's extracted text when path is one files/ or uploads/ file leaf.",
+ "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, memories) by default, or an exact supported file leaf's content when path selects one.",
},
toolTitle: {
type: 'string',
@@ -3134,6 +3185,25 @@ export const Grep: ToolCatalogEntry = {
},
}
+export const InterruptAgent: ToolCatalogEntry = {
+ id: 'interrupt_agent',
+ name: 'interrupt_agent',
+ route: 'go',
+ mode: 'sync',
+ parameters: {
+ type: 'object',
+ properties: {
+ agent_id: { type: 'string', description: 'The agent id to interrupt.' },
+ reason: {
+ type: 'string',
+ description:
+ "Why you are stopping it, in a few words. Recorded in the agent's final status.",
+ },
+ },
+ required: ['agent_id'],
+ },
+}
+
export const Knowledge: ToolCatalogEntry = {
id: 'knowledge',
name: 'knowledge',
@@ -3142,191 +3212,38 @@ export const Knowledge: ToolCatalogEntry = {
parameters: {
properties: {
request: { description: 'What knowledge base action is needed.', type: 'string' },
+ sessionId: {
+ description:
+ 'Reusable session ID returned by an earlier knowledge call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message. Omit it for a new or independent task.',
+ type: 'string',
+ },
+ title: {
+ description:
+ "Required private orchestration label (3–8 words) for this session's stable objective. Stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator; not shown to the knowledge agent. When resuming with sessionId, copy the registry title unchanged.",
+ maxLength: 120,
+ type: 'string',
+ },
},
- required: ['request'],
+ required: ['request', 'title'],
type: 'object',
},
subagentId: 'knowledge',
internal: true,
}
-export const KnowledgeBase: ToolCatalogEntry = {
- id: 'knowledge_base',
- name: 'knowledge_base',
+export const ListDeploymentVersions: ToolCatalogEntry = {
+ id: 'list_deployment_versions',
+ name: 'list_deployment_versions',
route: 'sim',
mode: 'async',
parameters: {
type: 'object',
properties: {
- args: {
- type: 'object',
- description: 'Arguments for the operation',
- properties: {
- apiKey: {
- type: 'string',
- description:
- 'API key for API-key-based connectors (required when connector auth mode is apiKey)',
- },
- chunkingConfig: {
- type: 'object',
- description: "Chunking configuration (optional for 'create')",
- properties: {
- maxSize: {
- type: 'number',
- description: 'Maximum chunk size (100-4000, default: 1024)',
- default: 1024,
- },
- minSize: {
- type: 'number',
- description: 'Minimum chunk size (1-2000, default: 1)',
- default: 1,
- },
- overlap: {
- type: 'number',
- description: 'Overlap between chunks (0-500, default: 200)',
- default: 200,
- },
- },
- },
- connectorId: {
- type: 'string',
- description:
- 'Connector ID (required for update_connector, delete_connector, sync_connector)',
- },
- connectorStatus: {
- type: 'string',
- description: 'Connector status (optional for update_connector)',
- enum: ['active', 'paused'],
- },
- connectorType: {
- type: 'string',
- description:
- "Connector type from registry, e.g. 'confluence', 'google_drive', 'notion' (required for add_connector). Read knowledgebases/connectors/{type}.json for the config schema.",
- },
- credentialId: {
- type: 'string',
- description:
- 'OAuth credential ID from environment/credentials.json (required for OAuth connectors)',
- },
- description: {
- type: 'string',
- description: "Description of the knowledge base (optional for 'create')",
- },
- disabledTagIds: {
- type: 'array',
- description:
- 'Tag definition IDs to opt out of (optional for add_connector). See tagDefinitions in the connector schema.',
- },
- documentId: { type: 'string', description: 'Document ID (required for update_document)' },
- documentIds: {
- type: 'array',
- description: 'Document IDs (for batch delete_document)',
- items: { type: 'string' },
- },
- enabled: {
- type: 'boolean',
- description: 'Enable/disable a document (optional for update_document)',
- },
- filePaths: {
- type: 'array',
- description:
- 'Canonical workspace file VFS paths to add as documents (for add_file), e.g. ["files/Docs/handbook.pdf"].',
- items: { type: 'string' },
- },
- filename: {
- type: 'string',
- description: 'New filename for a document (optional for update_document)',
- },
- knowledgeBaseId: {
- type: 'string',
- description:
- 'Knowledge base ID (required for get, query, add_file, list_tags, create_tag, get_tag_usage)',
- },
- knowledgeBaseIds: {
- type: 'array',
- description: 'Knowledge base IDs (for batch delete)',
- items: { type: 'string' },
- },
- name: {
- type: 'string',
- description: "Name of the knowledge base (required for 'create')",
- },
- query: { type: 'string', description: "Search query text (required for 'query')" },
- sourceConfig: {
- type: 'object',
- description:
- 'Connector-specific configuration matching the configFields in knowledgebases/connectors/{type}.json',
- },
- syncIntervalMinutes: {
- type: 'number',
- description:
- 'Sync interval in minutes. Accepted values: 60 (hourly), 360 (6h), 1440 (daily), 10080 (weekly), 0 (manual only). Default: 1440',
- default: 1440,
- },
- tagDefinitionId: {
- type: 'string',
- description: 'Tag definition ID (required for update_tag, delete_tag)',
- },
- tagDisplayName: {
- type: 'string',
- description:
- 'Display name for the tag (required for create_tag, optional for update_tag)',
- },
- tagFieldType: {
- type: 'string',
- description:
- 'Field type: text, number, date, boolean (optional for create_tag, defaults to text)',
- enum: ['text', 'number', 'date', 'boolean'],
- },
- topK: {
- type: 'number',
- description: 'Number of results to return (1-50, default: 5)',
- default: 5,
- },
- workspaceId: {
- type: 'string',
- description:
- "Workspace ID. Required for 'create' when there is no workspace in context; otherwise the current workspace context is used.",
- },
- },
- },
- operation: {
+ workflowId: {
type: 'string',
- description: 'The operation to perform',
- enum: [
- 'create',
- 'get',
- 'query',
- 'add_file',
- 'update',
- 'delete_document',
- 'update_document',
- 'list_tags',
- 'create_tag',
- 'update_tag',
- 'delete_tag',
- 'get_tag_usage',
- 'add_connector',
- 'update_connector',
- 'delete_connector',
- 'sync_connector',
- ],
+ description: 'Optional workflow ID. If not provided, uses the current workflow in context.',
},
},
- required: ['operation'],
- },
- resultSchema: {
- type: 'object',
- properties: {
- data: {
- type: ['object', 'array'],
- description:
- 'Operation-specific result payload. An object for most operations; list_tags and get_tag_usage return an array of tag definitions.',
- },
- message: { type: 'string', description: 'Human-readable outcome summary.' },
- success: { type: 'boolean', description: 'Whether the operation succeeded.' },
- },
- required: ['success', 'message'],
},
}
@@ -3348,14 +3265,6 @@ export const ListIntegrationTools: ToolCatalogEntry = {
},
}
-export const ListUserWorkspaces: ToolCatalogEntry = {
- id: 'list_user_workspaces',
- name: 'list_user_workspaces',
- route: 'sim',
- mode: 'async',
- parameters: { type: 'object', properties: {} },
-}
-
export const ListWorkspaceMcpServers: ToolCatalogEntry = {
id: 'list_workspace_mcp_servers',
name: 'list_workspace_mcp_servers',
@@ -3528,9 +3437,209 @@ export const ManageCustomTool: ToolCatalogEntry = {
requiredPermission: 'write',
}
-export const ManageMcpTool: ToolCatalogEntry = {
- id: 'manage_mcp_tool',
- name: 'manage_mcp_tool',
+export const ManageKnowledgeBase: ToolCatalogEntry = {
+ id: 'manage_knowledge_base',
+ name: 'manage_knowledge_base',
+ route: 'sim',
+ mode: 'async',
+ parameters: {
+ type: 'object',
+ properties: {
+ args: {
+ type: 'object',
+ description: 'Arguments for the operation',
+ properties: {
+ apiKey: {
+ type: 'string',
+ description:
+ 'API key for API-key-based connectors (required when connector auth mode is apiKey). Accepts an environment-variable reference — {{NAME}} — resolved server-side from workspace/user environment variables; a raw key also works.',
+ },
+ chunkingConfig: {
+ type: 'object',
+ description: "Chunking configuration (optional for 'create')",
+ properties: {
+ maxSize: {
+ type: 'number',
+ description: 'Maximum chunk size (100-4000, default: 1024)',
+ default: 1024,
+ },
+ minSize: {
+ type: 'number',
+ description: 'Minimum chunk size (1-2000, default: 100)',
+ default: 1,
+ },
+ overlap: {
+ type: 'number',
+ description: 'Overlap between chunks (0-500, default: 200)',
+ default: 200,
+ },
+ },
+ },
+ connectorId: {
+ type: 'string',
+ description:
+ 'Connector ID (required for update_connector, delete_connector, sync_connector)',
+ },
+ connectorStatus: {
+ type: 'string',
+ description: 'Connector status (optional for update_connector)',
+ enum: ['active', 'paused'],
+ },
+ connectorType: {
+ type: 'string',
+ description:
+ "Connector type from registry, e.g. 'confluence', 'google_drive', 'notion' (required for add_connector). Read knowledgebases/connectors/{type}.json for the config schema.",
+ },
+ credentialId: {
+ type: 'string',
+ description:
+ 'OAuth credential ID from environment/credentials.json (required for OAuth connectors)',
+ },
+ description: {
+ type: 'string',
+ description: "Description of the knowledge base (optional for 'create')",
+ },
+ disabledTagIds: {
+ type: 'array',
+ description:
+ 'Tag definition IDs to opt out of (optional for add_connector). See tagDefinitions in the connector schema.',
+ },
+ documentId: { type: 'string', description: 'Document ID (required for update_document)' },
+ documentIds: {
+ type: 'array',
+ description: 'Document IDs (for batch delete_document)',
+ items: { type: 'string' },
+ },
+ enabled: {
+ type: 'boolean',
+ description: 'Enable/disable a document (optional for update_document)',
+ },
+ filePaths: {
+ type: 'array',
+ description:
+ 'Canonical workspace file VFS paths to add as documents (for add_file), e.g. ["files/Docs/handbook.pdf"].',
+ items: { type: 'string' },
+ },
+ filename: {
+ type: 'string',
+ description: 'New filename for a document (optional for update_document)',
+ },
+ knowledgeBaseId: {
+ type: 'string',
+ description:
+ 'Knowledge base ID (required for get, query, add_file, list_tags, create_tag, get_tag_usage)',
+ },
+ knowledgeBaseIds: {
+ type: 'array',
+ description: 'Knowledge base IDs (for batch delete)',
+ items: { type: 'string' },
+ },
+ name: {
+ type: 'string',
+ description: "Name of the knowledge base (required for 'create')",
+ },
+ query: { type: 'string', description: "Search query text (required for 'query')" },
+ sourceConfig: {
+ type: 'object',
+ description:
+ 'Connector-specific configuration matching the configFields in knowledgebases/connectors/{type}.json',
+ },
+ syncIntervalMinutes: {
+ type: 'number',
+ description:
+ 'Sync interval in minutes. Accepted values: 60 (hourly), 360 (6h), 1440 (daily), 10080 (weekly), 0 (manual only). Default: 1440',
+ default: 1440,
+ },
+ tagDefinitionId: {
+ type: 'string',
+ description: 'Tag definition ID (required for update_tag, delete_tag)',
+ },
+ tagDisplayName: {
+ type: 'string',
+ description:
+ 'Display name for the tag (required for create_tag, optional for update_tag)',
+ },
+ tagFieldType: {
+ type: 'string',
+ description:
+ 'Field type: text, number, date, boolean (optional for create_tag, defaults to text)',
+ enum: ['text', 'number', 'date', 'boolean'],
+ },
+ tagValues: {
+ type: 'array',
+ description:
+ 'Typed tag values to set on this document (optional for update_document). Resolve tagDefinitionId with list_tags first. Use null to clear a value.',
+ items: {
+ type: 'object',
+ properties: {
+ tagDefinitionId: {
+ type: 'string',
+ description: 'Tag definition ID returned by list_tags.',
+ },
+ value: {
+ type: ['string', 'number', 'boolean', 'null'],
+ description:
+ "Value matching the tag definition's field type: string for text, number for number, YYYY-MM-DD string for date, boolean for boolean, or null to clear.",
+ },
+ },
+ required: ['tagDefinitionId', 'value'],
+ },
+ },
+ topK: {
+ type: 'number',
+ description: 'Number of results to return (1-100, default: 5)',
+ default: 5,
+ },
+ workspaceId: {
+ type: 'string',
+ description:
+ "Workspace ID. Required for 'create' when there is no workspace in context; otherwise the current workspace context is used.",
+ },
+ },
+ },
+ operation: {
+ type: 'string',
+ description: 'The operation to perform',
+ enum: [
+ 'create',
+ 'get',
+ 'query',
+ 'add_file',
+ 'update',
+ 'delete_document',
+ 'update_document',
+ 'list_tags',
+ 'create_tag',
+ 'update_tag',
+ 'delete_tag',
+ 'get_tag_usage',
+ 'add_connector',
+ 'update_connector',
+ 'delete_connector',
+ 'sync_connector',
+ ],
+ },
+ },
+ required: ['operation'],
+ },
+ resultSchema: {
+ type: 'object',
+ properties: {
+ data: {
+ type: ['object', 'array'],
+ description:
+ 'Operation-specific result payload. An object for most operations; list_tags and get_tag_usage return an array of tag definitions.',
+ },
+ message: { type: 'string', description: 'Human-readable outcome summary.' },
+ success: { type: 'boolean', description: 'Whether the operation succeeded.' },
+ },
+ required: ['success', 'message'],
+ },
+}
+
+export const ManageMcpConnection: ToolCatalogEntry = {
+ id: 'manage_mcp_connection',
+ name: 'manage_mcp_connection',
route: 'sim',
mode: 'async',
parameters: {
@@ -3546,7 +3655,8 @@ export const ManageMcpTool: ToolCatalogEntry = {
},
headers: {
type: 'object',
- description: 'Optional HTTP headers to send with requests (key-value pairs)',
+ description:
+ 'Optional HTTP headers to send with requests (key-value pairs). Values accept {{ENV_VAR}} references, resolved per-user at connect time — prefer them over pasting raw tokens.',
},
name: { type: 'string', description: 'Display name for the MCP server' },
timeout: {
@@ -3670,33 +3780,6 @@ export const ManageSkill: ToolCatalogEntry = {
requiredPermission: 'write',
}
-export const MaterializeFile: ToolCatalogEntry = {
- id: 'materialize_file',
- name: 'materialize_file',
- route: 'sim',
- mode: 'async',
- parameters: {
- type: 'object',
- properties: {
- fileNames: {
- type: 'array',
- description:
- 'The names of the uploaded files to materialize (e.g. ["report.pdf", "data.csv"])',
- items: { type: 'string' },
- },
- operation: {
- type: 'string',
- description:
- 'What to do with the file. "save" promotes it to a permanent files/ path. "import" imports a workflow JSON as a workspace workflow. "extract" decompresses a .zip upload into files//. Defaults to "save".',
- enum: ['save', 'import', 'extract'],
- default: 'save',
- },
- },
- required: ['fileNames'],
- },
- requiredPermission: 'write',
-}
-
export const Media: ToolCatalogEntry = {
id: 'media',
name: 'media',
@@ -3838,6 +3921,11 @@ export const OpenResource: ToolCatalogEntry = {
description: 'The resource type.',
enum: ['workflow', 'table', 'knowledgebase', 'file', 'log'],
},
+ view: {
+ type: 'string',
+ description:
+ 'Saved table view to open pinned (type "table" only): a view ID from the table\'s views.json. The panel opens the table with that view\'s filter/sort active. Omit to open the table on its default view.',
+ },
},
required: ['type'],
},
@@ -3847,6 +3935,149 @@ export const OpenResource: ToolCatalogEntry = {
},
}
+export const Platform: ToolCatalogEntry = {
+ id: 'platform',
+ name: 'platform',
+ route: 'subagent',
+ mode: 'async',
+ parameters: {
+ properties: {
+ task: {
+ description:
+ "A fully self-contained question about Sim — the platform agent sees none of this conversation, so include every name, id, constraint, and prior finding it needs. Example: 'what is the minimum schedule-trigger interval, and does it differ by plan?' or 'does the agent block persist memory across runs?'.",
+ type: 'string',
+ },
+ },
+ required: ['task'],
+ type: 'object',
+ },
+ subagentId: 'platform',
+ internal: true,
+}
+
+export const PrepareFileEdit: ToolCatalogEntry = {
+ id: 'prepare_file_edit',
+ name: 'prepare_file_edit',
+ route: 'sim',
+ mode: 'async',
+ parameters: {
+ type: 'object',
+ properties: {
+ operation: {
+ type: 'string',
+ description: 'The file operation to perform.',
+ enum: ['append', 'update', 'patch'],
+ },
+ target: {
+ type: 'object',
+ description: 'Explicit file target. Use kind=path + path for existing files.',
+ properties: {
+ kind: {
+ type: 'string',
+ description: 'How the file target is identified.',
+ enum: ['path'],
+ },
+ path: {
+ type: 'string',
+ description:
+ 'Canonical existing workspace file VFS path, e.g. "files/Reports/report.md". Required when target.kind=path.',
+ },
+ },
+ required: ['kind'],
+ },
+ title: {
+ type: 'string',
+ description:
+ 'Required short UI label for this content unit, e.g. "Chapter 1", "Slide 3", or "Fix footer spacing".',
+ },
+ contentType: {
+ type: 'string',
+ description:
+ 'Optional MIME type override. Usually omit and let the system infer from the target file extension.',
+ enum: [
+ 'text/markdown',
+ 'text/html',
+ 'text/plain',
+ 'application/json',
+ 'text/csv',
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'application/pdf',
+ ],
+ },
+ edit: {
+ type: 'object',
+ description:
+ 'Patch metadata. Use strategy=search_replace for exact text replacement, or strategy=anchored for line-based inserts/replacements/deletions. The actual replacement/insert content is provided via the paired apply_file_edit tool call.',
+ properties: {
+ after_anchor: {
+ type: 'string',
+ description:
+ 'Boundary line kept after inserted replacement content. Required for mode=replace_between.',
+ },
+ anchor: {
+ type: 'string',
+ description:
+ 'Anchor line after which new content is inserted. Required for mode=insert_after.',
+ },
+ before_anchor: {
+ type: 'string',
+ description:
+ 'Boundary line kept before inserted replacement content. Required for mode=replace_between.',
+ },
+ end_anchor: {
+ type: 'string',
+ description: 'First line to keep after deletion. Required for mode=delete_between.',
+ },
+ mode: {
+ type: 'string',
+ description: 'Anchored edit mode when strategy=anchored.',
+ enum: ['replace_between', 'insert_after', 'delete_between'],
+ },
+ occurrence: {
+ type: 'number',
+ description: '1-based occurrence for repeated anchor lines. Optional; defaults to 1.',
+ },
+ replaceAll: {
+ type: 'boolean',
+ description:
+ 'When true and strategy=search_replace, replace every match instead of requiring a unique single match.',
+ },
+ search: {
+ type: 'string',
+ description:
+ 'Exact text to find when strategy=search_replace. Must match exactly once unless replaceAll=true.',
+ },
+ start_anchor: {
+ type: 'string',
+ description: 'First line to delete. Required for mode=delete_between.',
+ },
+ strategy: {
+ type: 'string',
+ description: 'Patch strategy.',
+ enum: ['search_replace', 'anchored'],
+ },
+ },
+ },
+ },
+ required: ['operation', 'target', 'title'],
+ },
+ resultSchema: {
+ type: 'object',
+ properties: {
+ data: {
+ type: 'object',
+ description:
+ 'Optional operation metadata such as file id, file name, size, and content type.',
+ },
+ message: { type: 'string', description: 'Human-readable summary of the outcome.' },
+ success: { type: 'boolean', description: 'Whether the file operation succeeded.' },
+ },
+ required: ['success', 'message'],
+ },
+ requiredPermission: 'write',
+}
+
export const PromoteToLive: ToolCatalogEntry = {
id: 'promote_to_live',
name: 'promote_to_live',
@@ -3871,6 +4102,118 @@ export const PromoteToLive: ToolCatalogEntry = {
requiresApproval: true,
}
+export const PublishCustomBlock: ToolCatalogEntry = {
+ id: 'publish_custom_block',
+ name: 'publish_custom_block',
+ route: 'sim',
+ mode: 'async',
+ parameters: {
+ type: 'object',
+ properties: {
+ action: {
+ type: 'string',
+ description: 'Whether to publish (deploy) or unpublish (undeploy) the custom block',
+ enum: ['deploy', 'undeploy'],
+ default: 'deploy',
+ },
+ description: {
+ type: 'string',
+ description: 'Short description shown in the block picker, max 280 characters',
+ },
+ exposedOutputs: {
+ type: 'array',
+ description:
+ "Outputs the block exposes, each mapping a child block output path to a friendly name (use get_block_outputs for valid paths). Omit to expose the terminal block's whole result",
+ items: {
+ type: 'object',
+ properties: {
+ blockId: { type: 'string', description: 'Block UUID inside the workflow' },
+ name: { type: 'string', description: 'Friendly output name shown on the block' },
+ path: {
+ type: 'string',
+ description:
+ "Dot-path into that block's output (from get_block_outputs relativeOutputs)",
+ },
+ },
+ required: ['blockId', 'path', 'name'],
+ },
+ },
+ iconUrl: {
+ type: 'string',
+ description:
+ 'Optional icon image for the block: a workspace file VFS path (e.g. "files/icon.png", copied into public icon storage at publish) or an https image URL. Omit to use the organization\'s default icon',
+ },
+ inputs: {
+ type: 'array',
+ description:
+ "Optional per-input placeholder overrides. Input names and types are derived from the workflow's input trigger and cannot be changed here",
+ items: {
+ type: 'object',
+ properties: {
+ id: { type: 'string', description: 'Stable id of the input trigger field' },
+ placeholder: {
+ type: 'string',
+ description: "Placeholder text shown in the block's input field",
+ },
+ },
+ required: ['id'],
+ },
+ },
+ name: {
+ type: 'string',
+ description:
+ 'Display name for the block, max 60 characters. REQUIRED the first time a workflow is published. When republishing an existing block, omit it to keep the current name or pass a new one to rename. Ignored for undeploy.',
+ },
+ workflowId: { type: 'string', description: 'Workflow ID (defaults to active workflow)' },
+ },
+ },
+ resultSchema: {
+ type: 'object',
+ properties: {
+ action: {
+ type: 'string',
+ description: 'Action performed by the tool, such as "deploy" or "undeploy".',
+ },
+ blockId: { type: 'string', description: 'Custom block record ID.' },
+ blockType: {
+ type: 'string',
+ description: 'Stable block type slug (custom_block_*) used in workflow state.',
+ },
+ deploymentConfig: {
+ type: 'object',
+ description:
+ "Structured deployment configuration keyed by surface name. Includes the block's type, name, description, icon, derived input fields, and exposed outputs.",
+ },
+ deploymentStatus: {
+ type: 'object',
+ description:
+ 'Structured per-surface deployment status keyed by surface name, including customBlock and the underlying api surface when applicable.',
+ },
+ deploymentType: {
+ type: 'string',
+ description:
+ 'Deployment surface this result describes. For publish_custom_block this is always "custom_block".',
+ },
+ isDeployed: {
+ type: 'boolean',
+ description: 'Whether the custom block is published after this tool call.',
+ },
+ name: { type: 'string', description: 'Display name of the custom block.' },
+ removed: {
+ type: 'boolean',
+ description: 'Whether the custom block was unpublished during an undeploy action.',
+ },
+ updated: {
+ type: 'boolean',
+ description: 'Whether an existing custom block was updated instead of created.',
+ },
+ workflowId: { type: 'string', description: 'Workflow ID the custom block is bound to.' },
+ },
+ required: ['deploymentType', 'deploymentStatus'],
+ },
+ requiredPermission: 'admin',
+}
+
export const QueryLogs: ToolCatalogEntry = {
id: 'query_logs',
name: 'query_logs',
@@ -3883,10 +4226,22 @@ export const QueryLogs: ToolCatalogEntry = {
type: 'string',
description: "Optional (view='full'): only return this block's span subtree.",
},
+ blockIds: {
+ type: 'array',
+ description:
+ "(view='full') Block ids to drill into, copied from the trace digest's blockId values. Preferred over blockName; several at once is fine.",
+ items: { type: 'string' },
+ },
blockName: {
type: 'string',
description: "Optional (view='full'): only return spans for this block name.",
},
+ bucket: {
+ type: 'string',
+ description:
+ "(view='stats') Calendar bucketing for the per-workflow series: 'day' or 'hour'. Omit for overall totals only.",
+ enum: ['day', 'hour'],
+ },
costOperator: {
type: 'string',
description: "Filter (view='list'): comparison operator for cost.",
@@ -3909,24 +4264,34 @@ export const QueryLogs: ToolCatalogEntry = {
type: 'number',
description: "Filter (view='list'): duration threshold (ms) paired with durationOperator.",
},
- endDate: { type: 'string', description: "Filter (view='list'): ISO end of the time range." },
+ endDate: {
+ type: 'string',
+ description: "Filter (view='list'/'stats'): ISO end of the time range.",
+ },
executionId: {
type: 'string',
description:
- "Required for 'overview'/'full': the execution to read. For 'list', an optional exact-match filter.",
+ "Required for 'trace'/'overview'/'full': the execution to read. For 'list', an optional exact-match filter.",
+ },
+ fields: {
+ type: 'array',
+ description:
+ "(view='full') Only load these payload fields per span: whole keys ('input', 'output', 'error') or dotted paths into them ('output.result.rows', 'input.query'). Dotted selections come back under 'selected' keyed by path. Use this to pull just the field you need instead of a block's entire I/O.",
+ items: { type: 'string' },
},
folderIds: {
type: 'string',
- description: "Filter (view='list'): comma-separated folder IDs (descendants included).",
+ description:
+ "Filter (view='list'/'stats'): comma-separated folder IDs (descendants included).",
},
folderName: {
type: 'string',
- description: "Filter (view='list'): substring match on folder name.",
+ description: "Filter (view='list'/'stats'): substring match on folder name.",
},
level: {
type: 'string',
description:
- "Filter (view='list'): comma-separated levels: error, info, running, pending. Default all.",
+ "Filter (view='list'/'stats'): comma-separated levels: error, info, running, pending. Default all.",
},
limit: { type: 'number', description: "Max results (view='list'), 1-200 (default 100)." },
pattern: {
@@ -3950,29 +4315,38 @@ export const QueryLogs: ToolCatalogEntry = {
},
startDate: {
type: 'string',
- description: "Filter (view='list'): ISO start of the time range.",
+ description: "Filter (view='list'/'stats'): ISO start of the time range.",
+ },
+ timezone: {
+ type: 'string',
+ description:
+ '(view=\'stats\') IANA timezone the buckets are computed in, e.g. "America/Los_Angeles". Defaults to UTC. Set this whenever the user\'s question is about "today"/"yesterday" in their local time.',
+ },
+ title: {
+ type: 'string',
+ description:
+ 'Short human-readable label for this query, shown as the tool row in the UI, e.g. "Counting Elder failures Aug 12-13" or "Reading the failed enrichment run". Always provide one — it is how the user follows what you are looking for.',
},
triggers: {
type: 'string',
- description: "Filter (view='list'): comma-separated trigger types.",
+ description: "Filter (view='list'/'stats'): comma-separated trigger types.",
},
view: {
type: 'string',
description:
- "Disclosure level: 'list' (summaries), 'overview' (one execution's trace tree, no I/O), or 'full' (one execution's trace spans with I/O).",
- enum: ['list', 'overview', 'full'],
+ "Disclosure level: 'stats' (aggregate counts), 'list' (summaries), 'trace' (one execution's condensed block digest), 'overview' (trace tree, no I/O), 'full' (spans with I/O). Defaults to 'trace' with executionId, else 'list'.",
+ enum: ['list', 'stats', 'trace', 'overview', 'full'],
},
workflowIds: {
type: 'string',
- description: "Filter (view='list'): comma-separated workflow IDs.",
+ description: "Filter (view='list'/'stats'): comma-separated workflow IDs.",
},
workflowName: {
type: 'string',
- description: "Filter (view='list'): substring match on workflow name.",
+ description: "Filter (view='list'/'stats'): substring match on workflow name.",
},
workspaceId: { type: 'string', description: 'Workspace ID to scope to.' },
},
- required: ['view'],
},
}
@@ -4001,7 +4375,7 @@ export const QueryUserTable: ToolCatalogEntry = {
limit: {
type: 'number',
description:
- 'Maximum rows per page for query_rows (optional). Omit to fetch the ENTIRE matching result in one response — the call fails if the result exceeds the 5MB budget (narrow with a filter or set a limit). With a limit, a page may end early at the byte budget with more remaining; a non-null nextCursor in the result means more rows exist (continue with cursor).',
+ 'Maximum rows per page for query_rows (optional, max 1000). Omitting it uses the 1000-row default page — the ENTIRE result is never returned in one call; a non-null nextCursor in the result means more rows exist (continue with cursor). A page may also end early at the byte budget with more remaining.',
},
order: {
type: 'array',
@@ -4010,6 +4384,11 @@ export const QueryUserTable: ToolCatalogEntry = {
},
rowId: { type: 'string', description: 'Row ID (required for get_row)' },
tableId: { type: 'string', description: 'Table ID (required for all operations)' },
+ view: {
+ type: 'string',
+ description:
+ "Saved view to query through (query_rows only): a view ID from the table's views.json. The view's saved filter ANDs with any filter you pass (query-within-the-view); its saved sort applies only when you pass no order. Layout fields (hidden columns, widths) are ignored — full rows come back. Manage views via the table agent.",
+ },
},
},
operation: {
@@ -4102,7 +4481,7 @@ export const Redeploy: ToolCatalogEntry = {
deploymentType: {
type: 'string',
description:
- 'Deployment surface this result describes. For deploy_api and redeploy this is always "api".',
+ 'Deployment surface this result describes. For deploy_as_api and redeploy this is always "api".',
},
examples: {
type: 'object',
@@ -4173,7 +4552,16 @@ export const RestoreResource: ToolCatalogEntry = {
type: {
type: 'string',
description: 'The resource type to restore.',
- enum: ['workflow', 'table', 'file', 'knowledgebase', 'folder', 'file_folder'],
+ enum: [
+ 'workflow',
+ 'table',
+ 'file',
+ 'knowledgebase',
+ 'folder',
+ 'file_folder',
+ 'table_folder',
+ 'knowledge_folder',
+ ],
},
},
required: ['type', 'id'],
@@ -4248,14 +4636,14 @@ export const RunBlock: ToolCatalogEntry = {
workflowId: {
type: 'string',
description:
- 'Optional workflow ID to run. If not provided, uses the current workflow in context.',
+ 'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.',
},
workflow_input: {
type: 'object',
description: 'JSON object with key-value mappings where each key is an input field name',
},
},
- required: ['blockId'],
+ required: ['workflowId', 'blockId'],
},
clientExecutable: true,
}
@@ -4354,6 +4742,56 @@ export const RunCode: ToolCatalogEntry = {
capabilities: ['file_input', 'directory_input', 'table_input'],
}
+export const RunEnrichment: ToolCatalogEntry = {
+ id: 'run_enrichment',
+ name: 'run_enrichment',
+ route: 'sim',
+ mode: 'async',
+ parameters: {
+ type: 'object',
+ properties: {
+ enrichmentId: {
+ type: 'string',
+ description:
+ "Which enrichment to run. Discover the full set and each one's inputs/outputs via table_enrichments.list_enrichments.",
+ enum: [
+ 'work-email',
+ 'phone-number',
+ 'company-domain',
+ 'company-info',
+ 'email-verification',
+ ],
+ },
+ inputs: {
+ type: 'object',
+ description:
+ 'Map of the enrichment\'s input id → value, e.g. { "fullName": "Jane Doe", "companyDomain": "acme.com" }. Provide a value for every required input.',
+ },
+ },
+ required: ['enrichmentId', 'inputs'],
+ },
+ resultSchema: {
+ type: 'object',
+ properties: {
+ matched: {
+ type: 'boolean',
+ description: 'True when a provider returned a non-empty result.',
+ },
+ provider: {
+ type: ['string', 'null'],
+ description:
+ 'Internal label of the provider that produced the result (billing/diagnostics only — do NOT surface it to the user), or null on no match.',
+ },
+ result: {
+ type: 'object',
+ description: 'Mapped output values from the winning provider (empty object on no match).',
+ },
+ },
+ required: ['matched', 'result'],
+ },
+ requiredPermission: 'write',
+}
+
export const RunFromBlock: ToolCatalogEntry = {
id: 'run_from_block',
name: 'run_from_block',
@@ -4376,18 +4814,169 @@ export const RunFromBlock: ToolCatalogEntry = {
workflowId: {
type: 'string',
description:
- 'Optional workflow ID to run. If not provided, uses the current workflow in context.',
+ 'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.',
},
workflow_input: {
type: 'object',
description: 'JSON object with key-value mappings where each key is an input field name',
},
},
- required: ['startBlockId'],
+ required: ['workflowId', 'startBlockId'],
},
clientExecutable: true,
}
+export const RunFunction: ToolCatalogEntry = {
+ id: 'run_function',
+ name: 'run_function',
+ route: 'sim',
+ mode: 'async',
+ parameters: {
+ type: 'object',
+ properties: {
+ code: {
+ type: 'string',
+ description:
+ 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Use each needed secret as {{VAR_NAME}}; the reference resolves to the value exactly as stored.',
+ },
+ inputs: {
+ type: 'object',
+ description:
+ 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.',
+ properties: {
+ directories: {
+ type: 'array',
+ description:
+ 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.',
+ items: {
+ type: 'object',
+ properties: {
+ path: {
+ type: 'string',
+ description:
+ 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/{path}".',
+ },
+ sandboxPath: {
+ type: 'string',
+ description:
+ 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.',
+ },
+ },
+ required: ['path'],
+ },
+ },
+ files: {
+ type: 'array',
+ description: 'Workspace files to mount into the sandbox.',
+ items: {
+ type: 'object',
+ properties: {
+ path: {
+ type: 'string',
+ description:
+ 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".',
+ },
+ sandboxPath: {
+ type: 'string',
+ description:
+ 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.',
+ },
+ },
+ required: ['path'],
+ },
+ },
+ tables: {
+ type: 'array',
+ description: 'Workspace tables to mount as CSV files.',
+ items: {
+ type: 'object',
+ properties: {
+ path: { type: 'string', description: 'Canonical VFS table path when available.' },
+ sandboxPath: {
+ type: 'string',
+ description: 'Optional full sandbox path for the mounted CSV.',
+ },
+ tableId: { type: 'string', description: 'Workspace table ID.' },
+ },
+ },
+ },
+ },
+ },
+ language: {
+ type: 'string',
+ description: 'Execution language.',
+ enum: ['javascript', 'python', 'shell'],
+ },
+ outputTable: {
+ type: 'string',
+ description:
+ 'Table ID to overwrite with the code\'s return value. Code MUST return an array of objects where keys match column names. All existing rows are replaced. Example: "tbl_abc123"',
+ },
+ outputs: {
+ type: 'object',
+ description:
+ 'Workspace files to create or overwrite from returned code results or sandbox-created files.',
+ properties: {
+ files: {
+ type: 'array',
+ description:
+ 'File outputs. Missing parent folders are created automatically for create mode.',
+ items: {
+ type: 'object',
+ properties: {
+ format: {
+ type: 'string',
+ description: 'Optional serialization format for returned values.',
+ enum: ['json', 'csv', 'txt', 'md', 'html'],
+ },
+ mimeType: {
+ type: 'string',
+ description: 'Optional MIME type override when inference is not enough.',
+ },
+ mode: {
+ type: 'string',
+ description: 'Create a new file or overwrite an existing file at path.',
+ enum: ['create', 'overwrite'],
+ },
+ path: {
+ type: 'string',
+ description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".',
+ },
+ sandboxPath: {
+ type: 'string',
+ description:
+ 'Optional full path to a file created inside the sandbox. Omit to save the code return value.',
+ },
+ },
+ required: ['path', 'mode'],
+ },
+ },
+ },
+ },
+ sandboxId: {
+ type: 'string',
+ description:
+ 'Optional Sim sandbox id from agent/sandboxes/{name}.json. DEFAULT-FIRST: omit this whenever the documented default run_function environment can do the job. Select a ready existing Sim sandbox only when a required third-party dependency, Debian system package, or managed CLI is known to be absent, or a default attempt failed specifically because it was missing. Never guess an id.',
+ },
+ timeout: {
+ type: 'number',
+ description:
+ 'Maximum execution time in SECONDS (Sim converts to milliseconds). The sandbox stops execution and returns a timeout error after this duration. Defaults to 10 seconds and is capped at 300 seconds regardless of plan.',
+ default: 10,
+ },
+ title: {
+ type: 'string',
+ description:
+ 'Short user-visible label for this execution, e.g. "Clean customer CSV", "Revenue chart", or "Query GitHub issues".',
+ },
+ },
+ required: ['code'],
+ },
+ requiredPermission: 'write',
+ requiresApproval: true,
+ capabilities: ['file_input', 'directory_input', 'file_output', 'table_input', 'table_output'],
+}
+
export const RunWorkflow: ToolCatalogEntry = {
id: 'run_workflow',
name: 'run_workflow',
@@ -4424,7 +5013,7 @@ export const RunWorkflow: ToolCatalogEntry = {
workflowId: {
type: 'string',
description:
- 'Optional workflow ID to run. If not provided, uses the current workflow in context.',
+ 'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.',
},
workflow_input: {
type: 'object',
@@ -4432,6 +5021,7 @@ export const RunWorkflow: ToolCatalogEntry = {
"JSON object matching the target trigger's inputSchema (from get_workflow_run_options). For external/webhook triggers this is the event payload; for API/Input triggers it is the form fields.",
},
},
+ required: ['workflowId'],
},
clientExecutable: true,
requiresApproval: true,
@@ -4472,7 +5062,7 @@ export const RunWorkflowUntilBlock: ToolCatalogEntry = {
workflowId: {
type: 'string',
description:
- 'Optional workflow ID to run. If not provided, uses the current workflow in context.',
+ 'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.',
},
workflow_input: {
type: 'object',
@@ -4480,32 +5070,37 @@ export const RunWorkflowUntilBlock: ToolCatalogEntry = {
"JSON object matching the target trigger's inputSchema (from get_workflow_run_options). For external/webhook triggers this is the event payload; for API/Input triggers it is the form fields.",
},
},
- required: ['stopAfterBlockId'],
+ required: ['workflowId', 'stopAfterBlockId'],
},
clientExecutable: true,
requiresApproval: true,
}
-export const ScrapePage: ToolCatalogEntry = {
- id: 'scrape_page',
- name: 'scrape_page',
- route: 'go',
- mode: 'sync',
+export const SaveUpload: ToolCatalogEntry = {
+ id: 'save_upload',
+ name: 'save_upload',
+ route: 'sim',
+ mode: 'async',
parameters: {
type: 'object',
properties: {
- include_links: {
- type: 'boolean',
- description: 'Extract all links from the page (default false)',
+ fileNames: {
+ type: 'array',
+ description:
+ 'The names of the uploaded files to materialize (e.g. ["report.pdf", "data.csv"])',
+ items: { type: 'string' },
},
- url: { type: 'string', description: 'The URL to scrape (must include https://)' },
- wait_for: {
+ operation: {
type: 'string',
- description: 'CSS selector to wait for before scraping (for JS-heavy pages)',
+ description:
+ 'What to do with the file. "save" promotes it to a permanent files/ path. "import" imports a workflow JSON as a workspace workflow. "extract" decompresses a .zip upload into files//. Defaults to "save".',
+ enum: ['save', 'import', 'extract'],
+ default: 'save',
},
},
- required: ['url'],
+ required: ['fileNames'],
},
+ requiredPermission: 'write',
}
export const Search: ToolCatalogEntry = {
@@ -4528,21 +5123,21 @@ export const Search: ToolCatalogEntry = {
internal: true,
}
-export const SearchDocumentation: ToolCatalogEntry = {
- id: 'search_documentation',
- name: 'search_documentation',
+export const SearchDocs: ToolCatalogEntry = {
+ id: 'search_docs',
+ name: 'search_docs',
route: 'sim',
mode: 'async',
parameters: {
type: 'object',
properties: {
- query: { type: 'string', description: 'The search query' },
- topK: {
- type: 'number',
+ path: {
+ type: 'string',
description:
- 'Number of results to return (default 10). Not clamped — keep it small, since each result is a full doc chunk.',
- default: 10,
+ 'Optional docs/ VFS path (a page such as docs/workflows/blocks/agent.mdx, or a section such as docs/workflows) that limits the search scope',
},
+ query: { type: 'string', description: 'The search query' },
+ topK: { type: 'number', description: 'Number of results (default 5, max 25)', default: 5 },
},
required: ['query'],
},
@@ -4649,67 +5244,6 @@ export const SearchLibraryDocs: ToolCatalogEntry = {
},
}
-export const SearchOnline: ToolCatalogEntry = {
- id: 'search_online',
- name: 'search_online',
- route: 'go',
- mode: 'sync',
- parameters: {
- type: 'object',
- properties: {
- category: {
- type: 'string',
- description: 'Filter by category',
- enum: [
- 'news',
- 'tweet',
- 'github',
- 'company',
- 'research paper',
- 'linkedin profile',
- 'pdf',
- 'personal site',
- ],
- },
- include_text: { type: 'boolean', description: 'Include page text content (default true)' },
- num_results: { type: 'number', description: 'Number of results (default 10, max 25)' },
- query: { type: 'string', description: 'Natural language search query' },
- toolTitle: {
- type: 'string',
- description:
- "Required short UI label fragment (e.g. 'Slack integrations'), not a full sentence.",
- },
- },
- required: ['query', 'toolTitle'],
- },
-}
-
-export const SearchPatterns: ToolCatalogEntry = {
- id: 'search_patterns',
- name: 'search_patterns',
- route: 'go',
- mode: 'sync',
- parameters: {
- type: 'object',
- properties: {
- limit: {
- type: 'integer',
- description: 'Maximum number of pattern examples to return per query (defaults to 3).',
- },
- queries: {
- type: 'array',
- description:
- 'Up to 3 descriptive strings explaining the workflow pattern(s) you need. Focus on intent and desired outcomes.',
- items: {
- type: 'string',
- description: 'Example: "how to automate wealthbox meeting notes into follow-up tasks"',
- },
- },
- },
- required: ['queries'],
- },
-}
-
export const SetBlockEnabled: ToolCatalogEntry = {
id: 'set_block_enabled',
name: 'set_block_enabled',
@@ -4874,20 +5408,642 @@ export const ShareFile: ToolCatalogEntry = {
requiredPermission: 'write',
}
+export const SteerAgent: ToolCatalogEntry = {
+ id: 'steer_agent',
+ name: 'steer_agent',
+ route: 'go',
+ mode: 'sync',
+ parameters: {
+ type: 'object',
+ properties: {
+ agent_id: { type: 'string', description: 'The agent id to steer.' },
+ content: {
+ type: 'string',
+ description: 'The instruction to deliver, phrased as you would brief a teammate mid-task.',
+ },
+ },
+ required: ['agent_id', 'content'],
+ },
+}
+
export const Table: ToolCatalogEntry = {
id: 'table',
name: 'table',
route: 'subagent',
mode: 'async',
parameters: {
- properties: { request: { description: 'What table action is needed.', type: 'string' } },
- required: ['request'],
+ properties: {
+ request: { description: 'What table action is needed.', type: 'string' },
+ sessionId: {
+ description:
+ 'Reusable session ID returned by an earlier table call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message. Omit it for a new or independent task.',
+ type: 'string',
+ },
+ title: {
+ description:
+ "Required private orchestration label (3–8 words) for this session's stable objective. Stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator; not shown to the table agent. When resuming with sessionId, copy the registry title unchanged.",
+ maxLength: 120,
+ type: 'string',
+ },
+ },
+ required: ['request', 'title'],
type: 'object',
},
subagentId: 'table',
internal: true,
}
+export const TableAutomations: ToolCatalogEntry = {
+ id: 'table_automations',
+ name: 'table_automations',
+ route: 'sim',
+ mode: 'async',
+ parameters: {
+ type: 'object',
+ properties: {
+ args: {
+ type: 'object',
+ description: 'Arguments for the operation',
+ properties: {
+ autoRun: {
+ type: 'boolean',
+ description:
+ "On add: true fires dep-satisfied rows immediately (only when the user explicitly asked); default false stages silently. On update: toggles the group's auto-fire on dep satisfaction.",
+ },
+ blockId: {
+ type: 'string',
+ description: 'Source block ID inside the workflow (add_workflow_group_output)',
+ },
+ columnName: {
+ type: 'string',
+ description:
+ 'Target column name: required for delete_workflow_group_output (the bound column to drop); optional for add_workflow_group_output (auto-derived from path)',
+ },
+ dependencies: {
+ type: 'object',
+ description:
+ 'Dependencies before a row runs: { columns?: string[] } of input column names that must be filled. Output columns of upstream groups are valid; a group cannot depend on its own outputs.',
+ properties: {
+ columns: {
+ type: 'array',
+ description: 'Input column names that must be filled before the group runs a row.',
+ items: { type: 'string' },
+ },
+ },
+ },
+ deploymentMode: {
+ type: 'string',
+ description:
+ 'Which workflow version rows execute: "live" (default, editable draft — edits take effect immediately) or "deployed" (latest active deployment; fails if the workflow was never deployed).',
+ enum: ['live', 'deployed'],
+ },
+ groupId: {
+ type: 'string',
+ description:
+ 'Workflow group ID (required for update_workflow_group, delete_workflow_group, add_workflow_group_output, delete_workflow_group_output)',
+ },
+ groupIds: {
+ type: 'array',
+ description: 'Workflow group IDs to fire (required for run_column, non-empty)',
+ items: { type: 'string' },
+ },
+ mappingUpdates: {
+ type: 'array',
+ description:
+ 'Surgical per-output remap for update_workflow_group: each entry repoints ONE existing output column to a new (blockId, path) without touching the rest. Stale cells clear and backfill from saved execution logs where possible. Discover valid pairs via list_workflow_outputs first.',
+ items: {
+ type: 'object',
+ properties: {
+ blockId: { type: 'string', description: 'New source block ID for this column.' },
+ columnName: {
+ type: 'string',
+ description: 'The existing output column to remap (must be bound to this group).',
+ },
+ path: { type: 'string', description: 'New dotted output path on the new block.' },
+ },
+ required: ['columnName', 'blockId', 'path'],
+ },
+ },
+ name: {
+ type: 'string',
+ description: 'Display name for the group (optional on add/update)',
+ },
+ outputPath: {
+ type: 'string',
+ description:
+ 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.',
+ },
+ outputs: {
+ type: 'array',
+ description:
+ 'Outputs to surface as columns for add_workflow_group: each { blockId, path, columnName?, columnType? }; columnName auto-derives from path, columnType from the leaf type. Validated against list_workflow_outputs — invalid picks return the valid options. For update_workflow_group prefer add/delete_workflow_group_output and mappingUpdates; pass outputs only to restructure the whole set.',
+ items: {
+ type: 'object',
+ properties: {
+ blockId: { type: 'string', description: 'Source block ID inside the workflow.' },
+ columnName: {
+ type: 'string',
+ description:
+ 'Optional target column name; auto-derived from the path when omitted.',
+ },
+ columnType: {
+ type: 'string',
+ description: 'Optional column type; defaults from the leaf type when omitted.',
+ enum: ['string', 'number', 'boolean', 'date', 'json'],
+ },
+ path: { type: 'string', description: 'Dotted output path on the block.' },
+ },
+ required: ['blockId', 'path'],
+ },
+ },
+ path: {
+ type: 'string',
+ description: 'Dotted output path on the block (add_workflow_group_output)',
+ },
+ rowId: { type: 'string', description: 'Row ID for cancel_table_runs with scope "row".' },
+ rowIds: {
+ type: 'array',
+ description:
+ 'Optional row scope for run_column: only these rows are candidates (server eligibility still applies); omit for the whole table.',
+ items: { type: 'string' },
+ },
+ runMode: {
+ type: 'string',
+ description:
+ 'Run mode for run_column: "incomplete" (default) re-runs only rows with no output or a last failure; "all" re-runs every dep-satisfied row.',
+ enum: ['incomplete', 'all'],
+ },
+ scope: {
+ type: 'string',
+ description:
+ 'Cancellation scope for cancel_table_runs: "all" (whole table) or "row" (requires rowId).',
+ enum: ['all', 'row'],
+ },
+ tableId: {
+ type: 'string',
+ description: 'Table ID (required for everything except list_workflow_outputs)',
+ },
+ workflowId: {
+ type: 'string',
+ description: 'Workflow ID (required for add_workflow_group and list_workflow_outputs)',
+ },
+ },
+ },
+ operation: {
+ type: 'string',
+ description: 'The automation operation to perform',
+ enum: [
+ 'list_workflow_outputs',
+ 'add_workflow_group',
+ 'update_workflow_group',
+ 'delete_workflow_group',
+ 'add_workflow_group_output',
+ 'delete_workflow_group_output',
+ 'run_column',
+ 'cancel_table_runs',
+ ],
+ },
+ },
+ required: ['operation', 'args'],
+ },
+ resultSchema: {
+ type: 'object',
+ properties: {
+ data: { type: 'object', description: 'Operation-specific result payload.' },
+ message: { type: 'string', description: 'Human-readable outcome summary.' },
+ success: { type: 'boolean', description: 'Whether the operation succeeded.' },
+ },
+ required: ['success', 'message'],
+ },
+}
+
+export const TableColumns: ToolCatalogEntry = {
+ id: 'table_columns',
+ name: 'table_columns',
+ route: 'sim',
+ mode: 'async',
+ parameters: {
+ type: 'object',
+ properties: {
+ args: {
+ type: 'object',
+ description: 'Arguments for the operation',
+ properties: {
+ column: {
+ type: 'object',
+ description:
+ 'Column definition for add_column: { name, type, unique?, position? }; select (enum) columns also take { options: [names], multiple?: true } — options is required for select.',
+ },
+ columnName: {
+ type: 'string',
+ description:
+ 'Column name (required for rename_column and update_column; single-column delete_column)',
+ },
+ columnNames: {
+ type: 'array',
+ description:
+ 'Array of column names to delete at once (preferred for multi-column delete_column)',
+ },
+ multiple: {
+ type: 'boolean',
+ description:
+ 'Whether a select cell may hold several options (default false). Switching true → false fails if any row has more than one selected.',
+ },
+ newName: { type: 'string', description: 'New column name (required for rename_column)' },
+ newType: {
+ type: 'string',
+ description:
+ 'New column type for update_column: string, number, boolean, date, json, select. Converting to select also requires options; conversion fails if an existing cell value matches no option. A multiple select round-trips through text as a comma-separated cell.',
+ },
+ options: {
+ type: 'array',
+ description:
+ 'Choices for a select (enum) column as display names, e.g. ["Open", "Closed"]. Required when creating or converting to select. On update_column this REPLACES the whole list, matched BY NAME — send the full list including options you keep; omitting one deletes it and clears its cells. Max 100.',
+ items: { type: 'string' },
+ },
+ outputPath: {
+ type: 'string',
+ description:
+ 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.',
+ },
+ tableId: { type: 'string', description: 'Table ID (required for every operation)' },
+ unique: {
+ type: 'boolean',
+ description:
+ 'Set or clear the column unique constraint (update_column; not supported on select columns)',
+ },
+ },
+ required: ['tableId'],
+ },
+ operation: {
+ type: 'string',
+ description: 'The column operation to perform',
+ enum: ['add_column', 'rename_column', 'delete_column', 'update_column'],
+ },
+ },
+ required: ['operation', 'args'],
+ },
+ resultSchema: {
+ type: 'object',
+ properties: {
+ data: { type: 'object', description: 'Operation-specific result payload.' },
+ message: { type: 'string', description: 'Human-readable outcome summary.' },
+ success: { type: 'boolean', description: 'Whether the operation succeeded.' },
+ },
+ required: ['success', 'message'],
+ },
+}
+
+export const TableEnrichments: ToolCatalogEntry = {
+ id: 'table_enrichments',
+ name: 'table_enrichments',
+ route: 'sim',
+ mode: 'async',
+ parameters: {
+ type: 'object',
+ properties: {
+ args: {
+ type: 'object',
+ description: 'Arguments for the operation',
+ properties: {
+ autoRun: {
+ type: 'boolean',
+ description:
+ 'true fires dep-satisfied rows immediately on add (only when the user explicitly asked); default false stages silently — fire later via table_automations run_column.',
+ },
+ dependencies: {
+ type: 'object',
+ description:
+ 'Optional dependency override: { columns?: string[] }; omit to default to the mapped input columns.',
+ properties: {
+ columns: {
+ type: 'array',
+ description:
+ 'Input column names that must be filled before the enrichment runs a row.',
+ items: { type: 'string' },
+ },
+ },
+ },
+ enrichmentId: {
+ type: 'string',
+ description:
+ 'Enrichment registry ID for add_enrichment — discover via list_enrichments (e.g. work-email, phone-number, company-domain, company-info).',
+ },
+ inputMappings: {
+ type: 'array',
+ description:
+ 'For add_enrichment: binds each enrichment input to an existing table column, as { inputName, columnName } where inputName is the enrichment input id from list_enrichments. Provide one for every required input.',
+ items: {
+ type: 'object',
+ properties: {
+ columnName: {
+ type: 'string',
+ description: 'Existing table column that supplies this input.',
+ },
+ inputName: {
+ type: 'string',
+ description: 'Enrichment input id to bind (from list_enrichments).',
+ },
+ },
+ required: ['inputName', 'columnName'],
+ },
+ },
+ name: {
+ type: 'string',
+ description:
+ "Optional display name for the enrichment column group; defaults to the enrichment's registry name.",
+ },
+ outputColumnNames: {
+ type: 'object',
+ description:
+ 'Optional output column name overrides, as { "": "" }; omit for defaults.',
+ additionalProperties: {
+ type: 'string',
+ description: 'Target column name for this enrichment output id.',
+ },
+ },
+ outputPath: {
+ type: 'string',
+ description:
+ 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.',
+ },
+ tableId: { type: 'string', description: 'Table ID (required for add_enrichment)' },
+ },
+ },
+ operation: {
+ type: 'string',
+ description: 'The enrichment operation to perform',
+ enum: ['list_enrichments', 'add_enrichment'],
+ },
+ },
+ required: ['operation', 'args'],
+ },
+ resultSchema: {
+ type: 'object',
+ properties: {
+ data: { type: 'object', description: 'Operation-specific result payload.' },
+ message: { type: 'string', description: 'Human-readable outcome summary.' },
+ success: { type: 'boolean', description: 'Whether the operation succeeded.' },
+ },
+ required: ['success', 'message'],
+ },
+}
+
+export const TableManage: ToolCatalogEntry = {
+ id: 'table_manage',
+ name: 'table_manage',
+ route: 'sim',
+ mode: 'async',
+ parameters: {
+ type: 'object',
+ properties: {
+ args: {
+ type: 'object',
+ description: 'Arguments for the operation',
+ properties: {
+ description: { type: 'string', description: 'Table description (optional for create)' },
+ filePath: {
+ type: 'string',
+ description:
+ 'Canonical workspace file VFS path for create_from_file / import_file, e.g. files/{path}/{name}',
+ },
+ mapping: {
+ type: 'object',
+ description:
+ 'Optional explicit CSV-header → table-column mapping for import_file, as { "csvHeader": "columnName" | null }. null skips that header; omit a header to auto-map by sanitized name.',
+ additionalProperties: {
+ type: ['string', 'null'],
+ description: 'Target column name on the table; null skips that CSV header.',
+ },
+ },
+ mode: {
+ type: 'string',
+ description:
+ 'Import mode for import_file: append (default) adds rows; replace truncates existing rows in a transaction first.',
+ enum: ['append', 'replace'],
+ },
+ name: { type: 'string', description: 'Table name (required for create)' },
+ newName: { type: 'string', description: 'New table name (required for rename)' },
+ outputPath: {
+ type: 'string',
+ description:
+ 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.',
+ },
+ schema: {
+ type: 'object',
+ description:
+ 'Table schema with a columns array (required for create). Each column: { name, type, unique? }; a select (enum) column also requires options (display names) and takes multiple?.',
+ },
+ tableId: {
+ type: 'string',
+ description: 'Table ID (required for import_file and rename)',
+ },
+ },
+ },
+ operation: {
+ type: 'string',
+ description: 'The lifecycle operation to perform',
+ enum: ['create', 'create_from_file', 'import_file', 'rename'],
+ },
+ },
+ required: ['operation', 'args'],
+ },
+ resultSchema: {
+ type: 'object',
+ properties: {
+ data: { type: 'object', description: 'Operation-specific result payload.' },
+ message: { type: 'string', description: 'Human-readable outcome summary.' },
+ success: { type: 'boolean', description: 'Whether the operation succeeded.' },
+ },
+ required: ['success', 'message'],
+ },
+}
+
+export const TableRows: ToolCatalogEntry = {
+ id: 'table_rows',
+ name: 'table_rows',
+ route: 'sim',
+ mode: 'async',
+ parameters: {
+ type: 'object',
+ properties: {
+ args: {
+ type: 'object',
+ description: 'Arguments for the operation',
+ properties: {
+ columnName: {
+ type: 'string',
+ description: 'Column to set when using the values map format of batch_update_rows',
+ },
+ data: {
+ type: 'object',
+ description:
+ 'Row data as column → value pairs (required for insert_row, update_row; the patch object for update_rows_by_filter). Select (enum) cells take the option NAME.',
+ },
+ filter: {
+ type: 'object',
+ description:
+ 'Predicate filter for update_rows_by_filter / delete_rows_by_filter: {"all":[...]} (AND) or {"any":[...]} (OR) of {field, op, value} leaves or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (* wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array. Single-select columns match by eq/ne/in/nin; multiple-select by contains/ncontains — values are option NAMES.',
+ },
+ limit: {
+ type: 'number',
+ description:
+ 'Optional cap on affected rows for the by-filter operations; omit to act on every match.',
+ },
+ outputPath: {
+ type: 'string',
+ description:
+ 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.',
+ },
+ position: {
+ type: 'integer',
+ description:
+ 'Zero-based index at which to insert the row (optional, insert_row only). Rows at and below shift down; omit to append.',
+ },
+ rowId: { type: 'string', description: 'Row ID (required for update_row, delete_row)' },
+ rowIds: {
+ type: 'array',
+ description: 'Array of row IDs to delete (required for batch_delete_rows)',
+ items: { type: 'string' },
+ },
+ rows: {
+ type: 'array',
+ description: 'Array of row data objects (required for batch_insert_rows)',
+ },
+ tableId: { type: 'string', description: 'Table ID (required for every operation)' },
+ updates: {
+ type: 'array',
+ description:
+ 'Array of per-row updates: [{ rowId, data: { col: val } }] (batch_update_rows format a)',
+ },
+ values: {
+ type: 'object',
+ description:
+ 'Map of rowId → value for single-column batch update (batch_update_rows format b, with columnName)',
+ },
+ },
+ required: ['tableId'],
+ },
+ operation: {
+ type: 'string',
+ description: 'The row operation to perform',
+ enum: [
+ 'insert_row',
+ 'batch_insert_rows',
+ 'update_row',
+ 'batch_update_rows',
+ 'delete_row',
+ 'batch_delete_rows',
+ 'update_rows_by_filter',
+ 'delete_rows_by_filter',
+ ],
+ },
+ },
+ required: ['operation', 'args'],
+ },
+ resultSchema: {
+ type: 'object',
+ properties: {
+ data: { type: 'object', description: 'Operation-specific result payload.' },
+ message: { type: 'string', description: 'Human-readable outcome summary.' },
+ success: { type: 'boolean', description: 'Whether the operation succeeded.' },
+ },
+ required: ['success', 'message'],
+ },
+}
+
+export const TableViews: ToolCatalogEntry = {
+ id: 'table_views',
+ name: 'table_views',
+ route: 'sim',
+ mode: 'async',
+ parameters: {
+ type: 'object',
+ properties: {
+ args: {
+ type: 'object',
+ description: 'Arguments for the operation',
+ properties: {
+ filter: {
+ type: 'object',
+ description:
+ 'Saved row predicate, same grammar as query_rows filters: {"all":[...]} / {"any":[...]} of {field, op, value} leaves with exact column NAMES. Omit or null for an unfiltered view.',
+ },
+ hiddenColumns: {
+ type: 'array',
+ description:
+ 'Column names to hide in the UI when this view is active. Display-only — queries through the view still return every column.',
+ items: { type: 'string' },
+ },
+ isDefault: {
+ type: 'boolean',
+ description:
+ "Make this view the table's default (at most one per table; setting it clears the previous default).",
+ },
+ name: {
+ type: 'string',
+ description:
+ 'View display name (required for create_view; optional rename on update_view). Free-form label; references always use the view ID, so names are purely display.',
+ },
+ sort: {
+ type: 'array',
+ description:
+ 'Saved ordered sort spec, e.g. [{"field":"due","direction":"asc"}], column NAMES. Omit or null for default ordering.',
+ },
+ tableId: { type: 'string', description: 'Table ID (required for every operation)' },
+ viewId: {
+ type: 'string',
+ description:
+ 'View ID (required for get_view, update_view, delete_view, set_default_view)',
+ },
+ },
+ required: ['tableId'],
+ },
+ operation: {
+ type: 'string',
+ description: 'The view operation to perform',
+ enum: [
+ 'list_views',
+ 'get_view',
+ 'create_view',
+ 'update_view',
+ 'delete_view',
+ 'set_default_view',
+ ],
+ },
+ },
+ required: ['operation', 'args'],
+ },
+ resultSchema: {
+ type: 'object',
+ properties: {
+ data: { type: 'object', description: 'Operation-specific result payload.' },
+ message: { type: 'string', description: 'Human-readable outcome summary.' },
+ success: { type: 'boolean', description: 'Whether the operation succeeded.' },
+ },
+ required: ['success', 'message'],
+ },
+}
+
+export const TailAgent: ToolCatalogEntry = {
+ id: 'tail_agent',
+ name: 'tail_agent',
+ route: 'go',
+ mode: 'sync',
+ parameters: {
+ type: 'object',
+ properties: {
+ agent_id: { type: 'string', description: 'The agent id to inspect.' },
+ max_chars: {
+ type: 'number',
+ description:
+ 'Max characters of activity to return. Default 4000; unread activity beyond the budget stays queued for your next tail.',
+ },
+ },
+ required: ['agent_id'],
+ },
+}
+
export const Terminal: ToolCatalogEntry = {
id: 'terminal',
name: 'terminal',
@@ -5027,7 +6183,7 @@ export const UpdateDeploymentVersion: ToolCatalogEntry = {
version: {
type: 'number',
description:
- 'The numeric deployment version number to update (use get_deployment_log to find it).',
+ 'The numeric deployment version number to update (use list_deployment_versions to find it).',
},
workflowId: {
type: 'string',
@@ -5421,6 +6577,145 @@ export const Wait: ToolCatalogEntry = {
},
}
+export const WaitAgents: ToolCatalogEntry = {
+ id: 'wait_agents',
+ name: 'wait_agents',
+ route: 'go',
+ mode: 'sync',
+ parameters: {
+ type: 'object',
+ properties: {
+ agent_ids: {
+ type: 'array',
+ description: 'The agent ids to wait on, as returned by their async launches.',
+ items: { type: 'string' },
+ },
+ mode: {
+ type: 'string',
+ description:
+ '"all" (default) wakes when every listed agent finishes; "any" wakes on the first.',
+ enum: ['all', 'any'],
+ },
+ timeout_seconds: {
+ type: 'number',
+ description:
+ 'Max seconds to sleep before waking anyway. Default 120, capped at 600. On timeout you get current statuses and can wait again.',
+ },
+ },
+ required: ['agent_ids'],
+ },
+}
+
+export const WebCrawl: ToolCatalogEntry = {
+ id: 'web_crawl',
+ name: 'web_crawl',
+ route: 'go',
+ mode: 'sync',
+ parameters: {
+ type: 'object',
+ properties: {
+ exclude_paths: {
+ type: 'array',
+ description: 'Skip URLs matching these patterns',
+ items: { type: 'string' },
+ },
+ include_paths: {
+ type: 'array',
+ description: 'Only crawl URLs matching these patterns',
+ items: { type: 'string' },
+ },
+ limit: { type: 'number', description: 'Maximum pages to crawl (default 10, max 50)' },
+ max_depth: { type: 'number', description: 'How deep to follow links (default 2)' },
+ url: { type: 'string', description: 'Starting URL to crawl from' },
+ },
+ required: ['url'],
+ },
+}
+
+export const WebFetch: ToolCatalogEntry = {
+ id: 'web_fetch',
+ name: 'web_fetch',
+ route: 'go',
+ mode: 'sync',
+ parameters: {
+ type: 'object',
+ properties: {
+ include_highlights: {
+ type: 'boolean',
+ description: 'Include key highlights (default false)',
+ },
+ include_summary: {
+ type: 'boolean',
+ description: 'Include AI-generated summary (default false)',
+ },
+ include_text: { type: 'boolean', description: 'Include full page text (default true)' },
+ urls: {
+ type: 'array',
+ description: 'URLs to get content from (max 10)',
+ items: { type: 'string' },
+ },
+ },
+ required: ['urls'],
+ },
+}
+
+export const WebScrape: ToolCatalogEntry = {
+ id: 'web_scrape',
+ name: 'web_scrape',
+ route: 'go',
+ mode: 'sync',
+ parameters: {
+ type: 'object',
+ properties: {
+ include_links: {
+ type: 'boolean',
+ description: 'Extract all links from the page (default false)',
+ },
+ url: { type: 'string', description: 'The URL to scrape (must include https://)' },
+ wait_for: {
+ type: 'string',
+ description: 'CSS selector to wait for before scraping (for JS-heavy pages)',
+ },
+ },
+ required: ['url'],
+ },
+}
+
+export const WebSearch: ToolCatalogEntry = {
+ id: 'web_search',
+ name: 'web_search',
+ route: 'go',
+ mode: 'sync',
+ parameters: {
+ type: 'object',
+ properties: {
+ category: {
+ type: 'string',
+ description: 'Filter by category',
+ enum: [
+ 'news',
+ 'tweet',
+ 'github',
+ 'company',
+ 'research paper',
+ 'linkedin profile',
+ 'pdf',
+ 'personal site',
+ ],
+ },
+ include_text: { type: 'boolean', description: 'Include page text content (default true)' },
+ num_results: { type: 'number', description: 'Number of results (default 10, max 25)' },
+ query: { type: 'string', description: 'Natural language search query' },
+ toolTitle: {
+ type: 'string',
+ description:
+ "Required short UI label fragment (e.g. 'Slack integrations'), not a full sentence.",
+ },
+ },
+ required: ['query', 'toolTitle'],
+ },
+}
+
export const Workflow: ToolCatalogEntry = {
id: 'workflow',
name: 'workflow',
@@ -5453,129 +6748,6 @@ export const Workflow: ToolCatalogEntry = {
internal: true,
}
-export const WorkspaceFile: ToolCatalogEntry = {
- id: 'workspace_file',
- name: 'workspace_file',
- route: 'sim',
- mode: 'async',
- parameters: {
- type: 'object',
- properties: {
- operation: {
- type: 'string',
- description: 'The file operation to perform.',
- enum: ['append', 'update', 'patch'],
- },
- target: {
- type: 'object',
- description: 'Explicit file target. Use kind=path + path for existing files.',
- properties: {
- kind: {
- type: 'string',
- description: 'How the file target is identified.',
- enum: ['path'],
- },
- path: {
- type: 'string',
- description:
- 'Canonical existing workspace file VFS path, e.g. "files/Reports/report.md". Required when target.kind=path.',
- },
- },
- required: ['kind'],
- },
- title: {
- type: 'string',
- description:
- 'Required short UI label for this content unit, e.g. "Chapter 1", "Slide 3", or "Fix footer spacing".',
- },
- contentType: {
- type: 'string',
- description:
- 'Optional MIME type override. Usually omit and let the system infer from the target file extension.',
- enum: [
- 'text/markdown',
- 'text/html',
- 'text/plain',
- 'application/json',
- 'text/csv',
- 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
- 'application/pdf',
- ],
- },
- edit: {
- type: 'object',
- description:
- 'Patch metadata. Use strategy=search_replace for exact text replacement, or strategy=anchored for line-based inserts/replacements/deletions. The actual replacement/insert content is provided via the paired edit_content tool call.',
- properties: {
- after_anchor: {
- type: 'string',
- description:
- 'Boundary line kept after inserted replacement content. Required for mode=replace_between.',
- },
- anchor: {
- type: 'string',
- description:
- 'Anchor line after which new content is inserted. Required for mode=insert_after.',
- },
- before_anchor: {
- type: 'string',
- description:
- 'Boundary line kept before inserted replacement content. Required for mode=replace_between.',
- },
- end_anchor: {
- type: 'string',
- description: 'First line to keep after deletion. Required for mode=delete_between.',
- },
- mode: {
- type: 'string',
- description: 'Anchored edit mode when strategy=anchored.',
- enum: ['replace_between', 'insert_after', 'delete_between'],
- },
- occurrence: {
- type: 'number',
- description: '1-based occurrence for repeated anchor lines. Optional; defaults to 1.',
- },
- replaceAll: {
- type: 'boolean',
- description:
- 'When true and strategy=search_replace, replace every match instead of requiring a unique single match.',
- },
- search: {
- type: 'string',
- description:
- 'Exact text to find when strategy=search_replace. Must match exactly once unless replaceAll=true.',
- },
- start_anchor: {
- type: 'string',
- description: 'First line to delete. Required for mode=delete_between.',
- },
- strategy: {
- type: 'string',
- description: 'Patch strategy.',
- enum: ['search_replace', 'anchored'],
- },
- },
- },
- },
- required: ['operation', 'target', 'title'],
- },
- resultSchema: {
- type: 'object',
- properties: {
- data: {
- type: 'object',
- description:
- 'Optional operation metadata such as file id, file name, size, and content type.',
- },
- message: { type: 'string', description: 'Human-readable summary of the outcome.' },
- success: { type: 'boolean', description: 'Whether the file operation succeeded.' },
- },
- required: ['success', 'message'],
- },
- requiredPermission: 'write',
-}
-
export const FfmpegOperation = {
overlayAudio: 'overlay_audio',
mixAudio: 'mix_audio',
@@ -5608,47 +6780,6 @@ export const FfmpegOperationValues = [
FfmpegOperation.probe,
] as const
-export const KnowledgeBaseOperation = {
- create: 'create',
- get: 'get',
- query: 'query',
- addFile: 'add_file',
- update: 'update',
- deleteDocument: 'delete_document',
- updateDocument: 'update_document',
- listTags: 'list_tags',
- createTag: 'create_tag',
- updateTag: 'update_tag',
- deleteTag: 'delete_tag',
- getTagUsage: 'get_tag_usage',
- addConnector: 'add_connector',
- updateConnector: 'update_connector',
- deleteConnector: 'delete_connector',
- syncConnector: 'sync_connector',
-} as const
-
-export type KnowledgeBaseOperation =
- (typeof KnowledgeBaseOperation)[keyof typeof KnowledgeBaseOperation]
-
-export const KnowledgeBaseOperationValues = [
- KnowledgeBaseOperation.create,
- KnowledgeBaseOperation.get,
- KnowledgeBaseOperation.query,
- KnowledgeBaseOperation.addFile,
- KnowledgeBaseOperation.update,
- KnowledgeBaseOperation.deleteDocument,
- KnowledgeBaseOperation.updateDocument,
- KnowledgeBaseOperation.listTags,
- KnowledgeBaseOperation.createTag,
- KnowledgeBaseOperation.updateTag,
- KnowledgeBaseOperation.deleteTag,
- KnowledgeBaseOperation.getTagUsage,
- KnowledgeBaseOperation.addConnector,
- KnowledgeBaseOperation.updateConnector,
- KnowledgeBaseOperation.deleteConnector,
- KnowledgeBaseOperation.syncConnector,
-] as const
-
export const ManageCredentialOperation = {
rename: 'rename',
delete: 'delete',
@@ -5679,21 +6810,62 @@ export const ManageCustomToolOperationValues = [
ManageCustomToolOperation.list,
] as const
-export const ManageMcpToolOperation = {
+export const ManageKnowledgeBaseOperation = {
+ create: 'create',
+ get: 'get',
+ query: 'query',
+ addFile: 'add_file',
+ update: 'update',
+ deleteDocument: 'delete_document',
+ updateDocument: 'update_document',
+ listTags: 'list_tags',
+ createTag: 'create_tag',
+ updateTag: 'update_tag',
+ deleteTag: 'delete_tag',
+ getTagUsage: 'get_tag_usage',
+ addConnector: 'add_connector',
+ updateConnector: 'update_connector',
+ deleteConnector: 'delete_connector',
+ syncConnector: 'sync_connector',
+} as const
+
+export type ManageKnowledgeBaseOperation =
+ (typeof ManageKnowledgeBaseOperation)[keyof typeof ManageKnowledgeBaseOperation]
+
+export const ManageKnowledgeBaseOperationValues = [
+ ManageKnowledgeBaseOperation.create,
+ ManageKnowledgeBaseOperation.get,
+ ManageKnowledgeBaseOperation.query,
+ ManageKnowledgeBaseOperation.addFile,
+ ManageKnowledgeBaseOperation.update,
+ ManageKnowledgeBaseOperation.deleteDocument,
+ ManageKnowledgeBaseOperation.updateDocument,
+ ManageKnowledgeBaseOperation.listTags,
+ ManageKnowledgeBaseOperation.createTag,
+ ManageKnowledgeBaseOperation.updateTag,
+ ManageKnowledgeBaseOperation.deleteTag,
+ ManageKnowledgeBaseOperation.getTagUsage,
+ ManageKnowledgeBaseOperation.addConnector,
+ ManageKnowledgeBaseOperation.updateConnector,
+ ManageKnowledgeBaseOperation.deleteConnector,
+ ManageKnowledgeBaseOperation.syncConnector,
+] as const
+
+export const ManageMcpConnectionOperation = {
add: 'add',
edit: 'edit',
delete: 'delete',
list: 'list',
} as const
-export type ManageMcpToolOperation =
- (typeof ManageMcpToolOperation)[keyof typeof ManageMcpToolOperation]
+export type ManageMcpConnectionOperation =
+ (typeof ManageMcpConnectionOperation)[keyof typeof ManageMcpConnectionOperation]
-export const ManageMcpToolOperationValues = [
- ManageMcpToolOperation.add,
- ManageMcpToolOperation.edit,
- ManageMcpToolOperation.delete,
- ManageMcpToolOperation.list,
+export const ManageMcpConnectionOperationValues = [
+ ManageMcpConnectionOperation.add,
+ ManageMcpConnectionOperation.edit,
+ ManageMcpConnectionOperation.delete,
+ ManageMcpConnectionOperation.list,
] as const
export const ManageSandboxOperation = {
@@ -5729,19 +6901,19 @@ export const ManageSkillOperationValues = [
ManageSkillOperation.list,
] as const
-export const MaterializeFileOperation = {
- save: 'save',
- import: 'import',
- extract: 'extract',
+export const PrepareFileEditOperation = {
+ append: 'append',
+ update: 'update',
+ patch: 'patch',
} as const
-export type MaterializeFileOperation =
- (typeof MaterializeFileOperation)[keyof typeof MaterializeFileOperation]
+export type PrepareFileEditOperation =
+ (typeof PrepareFileEditOperation)[keyof typeof PrepareFileEditOperation]
-export const MaterializeFileOperationValues = [
- MaterializeFileOperation.save,
- MaterializeFileOperation.import,
- MaterializeFileOperation.extract,
+export const PrepareFileEditOperationValues = [
+ PrepareFileEditOperation.append,
+ PrepareFileEditOperation.update,
+ PrepareFileEditOperation.patch,
] as const
export const QueryUserTableOperation = {
@@ -5761,6 +6933,20 @@ export const QueryUserTableOperationValues = [
QueryUserTableOperation.queryRows,
] as const
+export const SaveUploadOperation = {
+ save: 'save',
+ import: 'import',
+ extract: 'extract',
+} as const
+
+export type SaveUploadOperation = (typeof SaveUploadOperation)[keyof typeof SaveUploadOperation]
+
+export const SaveUploadOperationValues = [
+ SaveUploadOperation.save,
+ SaveUploadOperation.import,
+ SaveUploadOperation.extract,
+] as const
+
export const SearchKnowledgeBaseOperation = {
get: 'get',
query: 'query',
@@ -5776,6 +6962,121 @@ export const SearchKnowledgeBaseOperationValues = [
SearchKnowledgeBaseOperation.listTags,
] as const
+export const TableAutomationsOperation = {
+ listWorkflowOutputs: 'list_workflow_outputs',
+ addWorkflowGroup: 'add_workflow_group',
+ updateWorkflowGroup: 'update_workflow_group',
+ deleteWorkflowGroup: 'delete_workflow_group',
+ addWorkflowGroupOutput: 'add_workflow_group_output',
+ deleteWorkflowGroupOutput: 'delete_workflow_group_output',
+ runColumn: 'run_column',
+ cancelTableRuns: 'cancel_table_runs',
+} as const
+
+export type TableAutomationsOperation =
+ (typeof TableAutomationsOperation)[keyof typeof TableAutomationsOperation]
+
+export const TableAutomationsOperationValues = [
+ TableAutomationsOperation.listWorkflowOutputs,
+ TableAutomationsOperation.addWorkflowGroup,
+ TableAutomationsOperation.updateWorkflowGroup,
+ TableAutomationsOperation.deleteWorkflowGroup,
+ TableAutomationsOperation.addWorkflowGroupOutput,
+ TableAutomationsOperation.deleteWorkflowGroupOutput,
+ TableAutomationsOperation.runColumn,
+ TableAutomationsOperation.cancelTableRuns,
+] as const
+
+export const TableColumnsOperation = {
+ addColumn: 'add_column',
+ renameColumn: 'rename_column',
+ deleteColumn: 'delete_column',
+ updateColumn: 'update_column',
+} as const
+
+export type TableColumnsOperation =
+ (typeof TableColumnsOperation)[keyof typeof TableColumnsOperation]
+
+export const TableColumnsOperationValues = [
+ TableColumnsOperation.addColumn,
+ TableColumnsOperation.renameColumn,
+ TableColumnsOperation.deleteColumn,
+ TableColumnsOperation.updateColumn,
+] as const
+
+export const TableEnrichmentsOperation = {
+ listEnrichments: 'list_enrichments',
+ addEnrichment: 'add_enrichment',
+} as const
+
+export type TableEnrichmentsOperation =
+ (typeof TableEnrichmentsOperation)[keyof typeof TableEnrichmentsOperation]
+
+export const TableEnrichmentsOperationValues = [
+ TableEnrichmentsOperation.listEnrichments,
+ TableEnrichmentsOperation.addEnrichment,
+] as const
+
+export const TableManageOperation = {
+ create: 'create',
+ createFromFile: 'create_from_file',
+ importFile: 'import_file',
+ rename: 'rename',
+} as const
+
+export type TableManageOperation = (typeof TableManageOperation)[keyof typeof TableManageOperation]
+
+export const TableManageOperationValues = [
+ TableManageOperation.create,
+ TableManageOperation.createFromFile,
+ TableManageOperation.importFile,
+ TableManageOperation.rename,
+] as const
+
+export const TableRowsOperation = {
+ insertRow: 'insert_row',
+ batchInsertRows: 'batch_insert_rows',
+ updateRow: 'update_row',
+ batchUpdateRows: 'batch_update_rows',
+ deleteRow: 'delete_row',
+ batchDeleteRows: 'batch_delete_rows',
+ updateRowsByFilter: 'update_rows_by_filter',
+ deleteRowsByFilter: 'delete_rows_by_filter',
+} as const
+
+export type TableRowsOperation = (typeof TableRowsOperation)[keyof typeof TableRowsOperation]
+
+export const TableRowsOperationValues = [
+ TableRowsOperation.insertRow,
+ TableRowsOperation.batchInsertRows,
+ TableRowsOperation.updateRow,
+ TableRowsOperation.batchUpdateRows,
+ TableRowsOperation.deleteRow,
+ TableRowsOperation.batchDeleteRows,
+ TableRowsOperation.updateRowsByFilter,
+ TableRowsOperation.deleteRowsByFilter,
+] as const
+
+export const TableViewsOperation = {
+ listViews: 'list_views',
+ getView: 'get_view',
+ createView: 'create_view',
+ updateView: 'update_view',
+ deleteView: 'delete_view',
+ setDefaultView: 'set_default_view',
+} as const
+
+export type TableViewsOperation = (typeof TableViewsOperation)[keyof typeof TableViewsOperation]
+
+export const TableViewsOperationValues = [
+ TableViewsOperation.listViews,
+ TableViewsOperation.getView,
+ TableViewsOperation.createView,
+ TableViewsOperation.updateView,
+ TableViewsOperation.deleteView,
+ TableViewsOperation.setDefaultView,
+] as const
+
export const TerminalOperation = {
run: 'run',
read: 'read',
@@ -5874,31 +7175,19 @@ export const UserTableOperationValues = [
UserTableOperation.addEnrichment,
] as const
-export const WorkspaceFileOperation = {
- append: 'append',
- update: 'update',
- patch: 'patch',
-} as const
-
-export type WorkspaceFileOperation =
- (typeof WorkspaceFileOperation)[keyof typeof WorkspaceFileOperation]
-
-export const WorkspaceFileOperationValues = [
- WorkspaceFileOperation.append,
- WorkspaceFileOperation.update,
- WorkspaceFileOperation.patch,
-] as const
-
export const TOOL_CATALOG: Record = {
- [Agent.id]: Agent,
+ [ApplyFileEdit.id]: ApplyFileEdit,
[Auth.id]: Auth,
[Browser.id]: Browser,
[BrowserClick.id]: BrowserClick,
+ [BrowserClickAt.id]: BrowserClickAt,
[BrowserCloseTab.id]: BrowserCloseTab,
+ [BrowserDrag.id]: BrowserDrag,
[BrowserExtract.id]: BrowserExtract,
[BrowserGoBack.id]: BrowserGoBack,
[BrowserGoForward.id]: BrowserGoForward,
[BrowserHover.id]: BrowserHover,
+ [BrowserInsertText.id]: BrowserInsertText,
[BrowserListSessions.id]: BrowserListSessions,
[BrowserListTabs.id]: BrowserListTabs,
[BrowserNavigate.id]: BrowserNavigate,
@@ -5906,7 +7195,6 @@ export const TOOL_CATALOG: Record = {
[BrowserOpenUrl.id]: BrowserOpenUrl,
[BrowserPressKey.id]: BrowserPressKey,
[BrowserReadText.id]: BrowserReadText,
- [BrowserRequestTakeover.id]: BrowserRequestTakeover,
[BrowserScreenshot.id]: BrowserScreenshot,
[BrowserScroll.id]: BrowserScroll,
[BrowserSelectOption.id]: BrowserSelectOption,
@@ -5915,26 +7203,23 @@ export const TOOL_CATALOG: Record = {
[BrowserType.id]: BrowserType,
[BrowserWaitFor.id]: BrowserWaitFor,
[CallIntegrationTool.id]: CallIntegrationTool,
- [CheckDeploymentStatus.id]: CheckDeploymentStatus,
+ [ConnectSlackBot.id]: ConnectSlackBot,
[Cp.id]: Cp,
- [CrawlWebsite.id]: CrawlWebsite,
- [CreateFile.id]: CreateFile,
+ [CreateEmptyFile.id]: CreateEmptyFile,
[CreateWorkflow.id]: CreateWorkflow,
[CreateWorkspaceMcpServer.id]: CreateWorkspaceMcpServer,
[DeleteWorkspaceMcpServer.id]: DeleteWorkspaceMcpServer,
[Deploy.id]: Deploy,
- [DeployApi.id]: DeployApi,
- [DeployChat.id]: DeployChat,
- [DeployCustomBlock.id]: DeployCustomBlock,
- [DeployMcp.id]: DeployMcp,
+ [DeployAsApi.id]: DeployAsApi,
+ [DeployAsChat.id]: DeployAsChat,
+ [DeployAsMcp.id]: DeployAsMcp,
[DiffWorkflows.id]: DiffWorkflows,
- [DownloadToWorkspaceFile.id]: DownloadToWorkspaceFile,
- [EditContent.id]: EditContent,
+ [DownloadFile.id]: DownloadFile,
[EditWorkflow.id]: EditWorkflow,
- [EnrichmentRun.id]: EnrichmentRun,
+ [Extensions.id]: Extensions,
+ [ExtractDocAssets.id]: ExtractDocAssets,
[Ffmpeg.id]: Ffmpeg,
[File.id]: File,
- [FunctionExecute.id]: FunctionExecute,
[GenerateApiKey.id]: GenerateApiKey,
[GenerateAudio.id]: GenerateAudio,
[GenerateImage.id]: GenerateImage,
@@ -5942,34 +7227,35 @@ export const TOOL_CATALOG: Record = {
[GetBlockOutputs.id]: GetBlockOutputs,
[GetBlockUpstreamReferences.id]: GetBlockUpstreamReferences,
[GetDeployedWorkflowState.id]: GetDeployedWorkflowState,
- [GetDeploymentLog.id]: GetDeploymentLog,
- [GetPageContents.id]: GetPageContents,
- [GetPlatformActions.id]: GetPlatformActions,
+ [GetDeploymentStatus.id]: GetDeploymentStatus,
[GetWorkflowData.id]: GetWorkflowData,
[GetWorkflowRunOptions.id]: GetWorkflowRunOptions,
[Glob.id]: Glob,
[Grep.id]: Grep,
+ [InterruptAgent.id]: InterruptAgent,
[Knowledge.id]: Knowledge,
- [KnowledgeBase.id]: KnowledgeBase,
+ [ListDeploymentVersions.id]: ListDeploymentVersions,
[ListIntegrationTools.id]: ListIntegrationTools,
- [ListUserWorkspaces.id]: ListUserWorkspaces,
[ListWorkspaceMcpServers.id]: ListWorkspaceMcpServers,
[LoadDeployment.id]: LoadDeployment,
[LoadIntegrationTool.id]: LoadIntegrationTool,
[LoadSkill.id]: LoadSkill,
[ManageCredential.id]: ManageCredential,
[ManageCustomTool.id]: ManageCustomTool,
- [ManageMcpTool.id]: ManageMcpTool,
+ [ManageKnowledgeBase.id]: ManageKnowledgeBase,
+ [ManageMcpConnection.id]: ManageMcpConnection,
[ManageSandbox.id]: ManageSandbox,
[ManageSkill.id]: ManageSkill,
- [MaterializeFile.id]: MaterializeFile,
[Media.id]: Media,
[Mkdir.id]: Mkdir,
[Mv.id]: Mv,
[OauthGetAuthLink.id]: OauthGetAuthLink,
[OauthRequestAccess.id]: OauthRequestAccess,
[OpenResource.id]: OpenResource,
+ [Platform.id]: Platform,
+ [PrepareFileEdit.id]: PrepareFileEdit,
[PromoteToLive.id]: PromoteToLive,
+ [PublishCustomBlock.id]: PublishCustomBlock,
[QueryLogs.id]: QueryLogs,
[QueryUserTable.id]: QueryUserTable,
[Read.id]: Read,
@@ -5980,27 +7266,39 @@ export const TOOL_CATALOG: Record = {
[Run.id]: Run,
[RunBlock.id]: RunBlock,
[RunCode.id]: RunCode,
+ [RunEnrichment.id]: RunEnrichment,
[RunFromBlock.id]: RunFromBlock,
+ [RunFunction.id]: RunFunction,
[RunWorkflow.id]: RunWorkflow,
[RunWorkflowUntilBlock.id]: RunWorkflowUntilBlock,
- [ScrapePage.id]: ScrapePage,
+ [SaveUpload.id]: SaveUpload,
[Search.id]: Search,
- [SearchDocumentation.id]: SearchDocumentation,
+ [SearchDocs.id]: SearchDocs,
[SearchIntegrationTools.id]: SearchIntegrationTools,
[SearchKnowledgeBase.id]: SearchKnowledgeBase,
[SearchLibraryDocs.id]: SearchLibraryDocs,
- [SearchOnline.id]: SearchOnline,
- [SearchPatterns.id]: SearchPatterns,
[SetBlockEnabled.id]: SetBlockEnabled,
[SetEnvironmentVariables.id]: SetEnvironmentVariables,
[SetGlobalWorkflowVariables.id]: SetGlobalWorkflowVariables,
[ShareFile.id]: ShareFile,
+ [SteerAgent.id]: SteerAgent,
[Table.id]: Table,
+ [TableAutomations.id]: TableAutomations,
+ [TableColumns.id]: TableColumns,
+ [TableEnrichments.id]: TableEnrichments,
+ [TableManage.id]: TableManage,
+ [TableRows.id]: TableRows,
+ [TableViews.id]: TableViews,
+ [TailAgent.id]: TailAgent,
[Terminal.id]: Terminal,
[UpdateDeploymentVersion.id]: UpdateDeploymentVersion,
[UpdateWorkspaceMcpServer.id]: UpdateWorkspaceMcpServer,
[UserTable.id]: UserTable,
[Wait.id]: Wait,
+ [WaitAgents.id]: WaitAgents,
+ [WebCrawl.id]: WebCrawl,
+ [WebFetch.id]: WebFetch,
+ [WebScrape.id]: WebScrape,
+ [WebSearch.id]: WebSearch,
[Workflow.id]: Workflow,
- [WorkspaceFile.id]: WorkspaceFile,
}
diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts
index b2c3c73b66..a6070e7f09 100644
--- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts
+++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts
@@ -10,18 +10,37 @@ export interface ToolRuntimeSchemaEntry {
}
export const TOOL_RUNTIME_SCHEMAS: Record = {
- agent: {
+ apply_file_edit: {
parameters: {
+ type: 'object',
properties: {
- request: {
- description: 'What tool/skill/MCP action is needed.',
+ content: {
type: 'string',
+ description:
+ 'The text content to write. For append: text to append. For update: full replacement text. For patch with search_replace: the replacement text. For patch with anchored: the insert/replacement text.',
},
},
- required: ['request'],
+ required: ['content'],
+ },
+ resultSchema: {
type: 'object',
+ properties: {
+ data: {
+ type: 'object',
+ description:
+ 'Optional operation metadata such as file id, file name, size, and content type.',
+ },
+ message: {
+ type: 'string',
+ description: 'Human-readable summary of the outcome.',
+ },
+ success: {
+ type: 'boolean',
+ description: 'Whether the content was applied successfully.',
+ },
+ },
+ required: ['success', 'message'],
},
- resultSchema: undefined,
},
auth: {
parameters: {
@@ -195,6 +214,160 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
required: ['dispatched'],
},
},
+ browser_click_at: {
+ parameters: {
+ type: 'object',
+ properties: {
+ clickCount: {
+ type: 'number',
+ description: '1 = single click (default), 2 = double-click, 3 = triple-click.',
+ },
+ x: {
+ type: 'number',
+ description:
+ "X in CSS pixels within the current viewport. When read off a browser_screenshot, divide the image pixel value by the screenshot's scale.",
+ },
+ y: {
+ type: 'number',
+ description:
+ 'Y in CSS pixels within the current viewport, converted from screenshot pixels the same way as x.',
+ },
+ },
+ required: ['x', 'y'],
+ },
+ resultSchema: {
+ type: 'object',
+ properties: {
+ activeTab: {
+ type: 'object',
+ description: 'New active tab after a tab-changing click.',
+ properties: {
+ tabId: {
+ type: 'string',
+ description: 'Stable browser tab id.',
+ },
+ url: {
+ type: 'string',
+ description: 'New active tab URL.',
+ },
+ },
+ },
+ clickCount: {
+ type: 'number',
+ description: 'The click count that was dispatched (1, 2, or 3).',
+ },
+ clickedAt: {
+ type: 'object',
+ description: 'The CSS-pixel viewport point that was clicked.',
+ properties: {
+ x: {
+ type: 'number',
+ description: 'Clicked X in CSS viewport pixels.',
+ },
+ y: {
+ type: 'number',
+ description: 'Clicked Y in CSS viewport pixels.',
+ },
+ },
+ },
+ dialogs: {
+ type: 'array',
+ description: 'Visible DOM dialogs remaining after the click.',
+ items: {
+ type: 'string',
+ },
+ },
+ dispatched: {
+ type: 'boolean',
+ description: 'Whether the native pointer click was dispatched at the point.',
+ },
+ effect: {
+ type: 'object',
+ description:
+ 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.',
+ properties: {
+ dialogChanged: {
+ type: 'boolean',
+ description: 'The visible DOM dialog set changed.',
+ },
+ domChanged: {
+ type: 'boolean',
+ description: 'The DOM mutation revision changed; weak evidence on its own.',
+ },
+ fieldChanged: {
+ type: 'boolean',
+ description: 'The safely inspectable focused-field state changed.',
+ },
+ focusChanged: {
+ type: 'boolean',
+ description: 'The focused element changed.',
+ },
+ popupChanged: {
+ type: 'boolean',
+ description: 'The visible popup/menu set changed.',
+ },
+ scrollChanged: {
+ type: 'boolean',
+ description: 'A tracked scroll offset changed; weak evidence except for scroll keys.',
+ },
+ tabChanged: {
+ type: 'boolean',
+ description: 'The active browser tab changed.',
+ },
+ targetChanged: {
+ type: 'boolean',
+ description: "The requested target's checked/selected/expanded/open state changed.",
+ },
+ titleChanged: {
+ type: 'boolean',
+ description: 'The document title changed; weak evidence on its own.',
+ },
+ urlChanged: {
+ type: 'boolean',
+ description: 'The observed URL changed.',
+ },
+ },
+ },
+ effectObserved: {
+ type: 'boolean',
+ description:
+ 'Whether a strong page change (URL/dialog/popup/target, or focus into an editable) followed the click.',
+ },
+ note: {
+ type: 'string',
+ description: 'Caution or follow-up guidance, present when the click needs verification.',
+ },
+ notices: {
+ type: 'array',
+ description:
+ 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.',
+ items: {
+ type: 'string',
+ },
+ },
+ possibleEffectObserved: {
+ type: 'boolean',
+ description: 'Whether only weaker background churn (DOM/title) was observed.',
+ },
+ target: {
+ type: 'string',
+ description:
+ 'What the point resolved to before the click (tag/role and accessible name) — confirm it matches the intended target.',
+ },
+ targetCursor: {
+ type: 'string',
+ description:
+ "The CSS cursor at the point (e.g. 'pointer', 'text', 'crosshair') — a hint about what kind of surface was hit.",
+ },
+ trusted: {
+ type: 'boolean',
+ description:
+ 'True — coordinate and drag input always use the trusted Chromium pointer pipeline.',
+ },
+ },
+ required: ['dispatched'],
+ },
+ },
browser_close_tab: {
parameters: {
type: 'object',
@@ -208,6 +381,171 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
+ browser_drag: {
+ parameters: {
+ type: 'object',
+ properties: {
+ fromElementId: {
+ type: 'number',
+ description:
+ 'Drag source element id from the latest snapshot. Alternative to fromX/fromY.',
+ },
+ fromX: {
+ type: 'number',
+ description:
+ 'Drag source X in CSS viewport pixels (paired with fromY) when no source element id is available.',
+ },
+ fromY: {
+ type: 'number',
+ description: 'Drag source Y in CSS viewport pixels.',
+ },
+ toElementId: {
+ type: 'number',
+ description: 'Drop target element id from the latest snapshot. Alternative to toX/toY.',
+ },
+ toX: {
+ type: 'number',
+ description: 'Drop target X in CSS viewport pixels (paired with toY).',
+ },
+ toY: {
+ type: 'number',
+ description: 'Drop target Y in CSS viewport pixels.',
+ },
+ },
+ },
+ resultSchema: {
+ type: 'object',
+ properties: {
+ dialogs: {
+ type: 'array',
+ description: 'Visible DOM dialogs remaining after the click.',
+ items: {
+ type: 'string',
+ },
+ },
+ dispatched: {
+ type: 'boolean',
+ description: 'Whether the full drag gesture was dispatched.',
+ },
+ effect: {
+ type: 'object',
+ description:
+ 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.',
+ properties: {
+ dialogChanged: {
+ type: 'boolean',
+ description: 'The visible DOM dialog set changed.',
+ },
+ domChanged: {
+ type: 'boolean',
+ description: 'The DOM mutation revision changed; weak evidence on its own.',
+ },
+ fieldChanged: {
+ type: 'boolean',
+ description: 'The safely inspectable focused-field state changed.',
+ },
+ focusChanged: {
+ type: 'boolean',
+ description: 'The focused element changed.',
+ },
+ popupChanged: {
+ type: 'boolean',
+ description: 'The visible popup/menu set changed.',
+ },
+ scrollChanged: {
+ type: 'boolean',
+ description: 'A tracked scroll offset changed; weak evidence except for scroll keys.',
+ },
+ tabChanged: {
+ type: 'boolean',
+ description: 'The active browser tab changed.',
+ },
+ targetChanged: {
+ type: 'boolean',
+ description: "The requested target's checked/selected/expanded/open state changed.",
+ },
+ titleChanged: {
+ type: 'boolean',
+ description: 'The document title changed; weak evidence on its own.',
+ },
+ urlChanged: {
+ type: 'boolean',
+ description: 'The observed URL changed.',
+ },
+ },
+ },
+ effectObserved: {
+ type: 'boolean',
+ description:
+ 'Whether an observable page change (DOM/URL/dialog/target/scroll) followed the drag.',
+ },
+ from: {
+ type: 'object',
+ description: 'The resolved drag source.',
+ properties: {
+ element: {
+ type: 'string',
+ description: 'What that endpoint resolved to, when known.',
+ },
+ x: {
+ type: 'number',
+ description: 'Endpoint X in CSS viewport pixels.',
+ },
+ y: {
+ type: 'number',
+ description: 'Endpoint Y in CSS viewport pixels.',
+ },
+ },
+ },
+ nativeHtml5Drag: {
+ type: 'boolean',
+ description:
+ 'True when the page started a native HTML5 drag and it was completed as a real drag-and-drop; false for a pointer-sensor drag.',
+ },
+ note: {
+ type: 'string',
+ description:
+ 'Caution or follow-up guidance — e.g. verify the drop with a fresh snapshot when no effect was observed.',
+ },
+ notices: {
+ type: 'array',
+ description:
+ 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.',
+ items: {
+ type: 'string',
+ },
+ },
+ possibleEffectObserved: {
+ type: 'boolean',
+ description: 'Whether only weaker background churn (DOM/title) was observed.',
+ },
+ to: {
+ type: 'object',
+ description: 'The resolved drop target.',
+ properties: {
+ element: {
+ type: 'string',
+ description: 'What that endpoint resolved to, when known.',
+ },
+ x: {
+ type: 'number',
+ description: 'Endpoint X in CSS viewport pixels.',
+ },
+ y: {
+ type: 'number',
+ description: 'Endpoint Y in CSS viewport pixels.',
+ },
+ },
+ },
+ trusted: {
+ type: 'boolean',
+ description:
+ 'True — coordinate and drag input always use the trusted Chromium pointer pipeline.',
+ },
+ },
+ required: ['dispatched'],
+ },
+ },
browser_extract: {
parameters: {
type: 'object',
@@ -391,6 +729,142 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
required: ['hovered'],
},
},
+ browser_insert_text: {
+ parameters: {
+ type: 'object',
+ properties: {
+ submit: {
+ type: 'boolean',
+ description: 'Press Enter after inserting. Default false.',
+ },
+ text: {
+ type: 'string',
+ description: 'The text to insert at the caret. Must be non-empty.',
+ },
+ },
+ required: ['text'],
+ },
+ resultSchema: {
+ type: 'object',
+ properties: {
+ activeElement: {
+ type: 'string',
+ description: 'Focused element kind after the action.',
+ },
+ dispatched: {
+ type: 'boolean',
+ description: 'Whether the text was inserted through the native IME pipeline.',
+ },
+ effect: {
+ type: 'object',
+ description:
+ 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.',
+ properties: {
+ dialogChanged: {
+ type: 'boolean',
+ description: 'The visible DOM dialog set changed.',
+ },
+ domChanged: {
+ type: 'boolean',
+ description: 'The DOM mutation revision changed; weak evidence on its own.',
+ },
+ fieldChanged: {
+ type: 'boolean',
+ description: 'The safely inspectable focused-field state changed.',
+ },
+ focusChanged: {
+ type: 'boolean',
+ description: 'The focused element changed.',
+ },
+ popupChanged: {
+ type: 'boolean',
+ description: 'The visible popup/menu set changed.',
+ },
+ scrollChanged: {
+ type: 'boolean',
+ description: 'A tracked scroll offset changed; weak evidence except for scroll keys.',
+ },
+ tabChanged: {
+ type: 'boolean',
+ description: 'The active browser tab changed.',
+ },
+ targetChanged: {
+ type: 'boolean',
+ description: "The requested target's checked/selected/expanded/open state changed.",
+ },
+ titleChanged: {
+ type: 'boolean',
+ description: 'The document title changed; weak evidence on its own.',
+ },
+ urlChanged: {
+ type: 'boolean',
+ description: 'The observed URL changed.',
+ },
+ },
+ },
+ effectObserved: {
+ type: 'boolean',
+ description:
+ 'Whether a field or page change confirmed the insertion. Canvas editors cannot echo one — verify visually.',
+ },
+ insertedChars: {
+ type: 'number',
+ description: 'How many characters were inserted.',
+ },
+ kind: {
+ type: 'string',
+ description:
+ 'The kind of focused editable that received the text (input:*, textarea, contenteditable, canvas, textbox-role).',
+ },
+ note: {
+ type: 'string',
+ description:
+ 'Caution or follow-up guidance, e.g. that a canvas editor needs visual verification.',
+ },
+ notices: {
+ type: 'array',
+ description:
+ 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.',
+ items: {
+ type: 'string',
+ },
+ },
+ possibleEffectObserved: {
+ type: 'boolean',
+ description: 'Whether only weaker background churn was observed.',
+ },
+ redacted: {
+ type: 'boolean',
+ description: 'Whether sensitive focused-field details were withheld.',
+ },
+ selectedChars: {
+ type: 'number',
+ description: 'Number of selected characters when safely inspectable.',
+ },
+ submitDispatched: {
+ type: 'boolean',
+ description: 'Whether Enter was actually dispatched.',
+ },
+ submitRequested: {
+ type: 'boolean',
+ description: 'Whether Enter was requested after insertion.',
+ },
+ trusted: {
+ type: 'boolean',
+ description: 'True — insertion uses the trusted input pipeline.',
+ },
+ valueLength: {
+ type: 'number',
+ description: 'Focused non-secret field length when safely inspectable.',
+ },
+ valuePreview: {
+ type: 'string',
+ description: 'Bounded focused non-secret field preview when safely inspectable.',
+ },
+ },
+ required: ['dispatched'],
+ },
+ },
browser_list_sessions: {
parameters: {
type: 'object',
@@ -629,26 +1103,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
},
},
- browser_request_takeover: {
- parameters: {
- type: 'object',
- properties: {
- purpose: {
- type: 'string',
- description:
- 'Why takeover is needed. Set sign_in for a login/password flow so the desktop can remember a privacy-preserving session hint after the user finishes.',
- enum: ['sign_in', 'captcha', 'payment', 'sensitive_confirmation', 'other'],
- },
- reason: {
- type: 'string',
- description:
- "Short explanation shown to the user of what they need to do (e.g. 'Sign in to Notion').",
- },
- },
- required: ['reason'],
- },
- resultSchema: undefined,
- },
browser_screenshot: {
parameters: {
type: 'object',
@@ -1106,15 +1560,31 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- check_deployment_status: {
+ connect_slack_bot: {
parameters: {
type: 'object',
properties: {
- workflowId: {
+ botTokenEnvVar: {
type: 'string',
- description: 'Workflow ID to check (defaults to current workflow)',
+ description:
+ 'NAME of the environment variable holding the bot token (xoxb-..., OAuth & Permissions → Bot User OAuth Token). Pass the variable name, never the token value.',
+ },
+ description: {
+ type: 'string',
+ description: 'Optional description shown on the credential.',
+ },
+ displayName: {
+ type: 'string',
+ description:
+ 'Display name for the credential, shown in the credential picker (e.g. "Elder Bot"). Must be unique in the workspace.',
+ },
+ signingSecretEnvVar: {
+ type: 'string',
+ description:
+ "NAME of the environment variable holding the Slack app's signing secret (Basic Information → App Credentials). Pass the variable name, never the secret value.",
},
},
+ required: ['displayName', 'signingSecretEnvVar', 'botTokenEnvVar'],
},
resultSchema: undefined,
},
@@ -1145,42 +1615,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- crawl_website: {
- parameters: {
- type: 'object',
- properties: {
- exclude_paths: {
- type: 'array',
- description: 'Skip URLs matching these patterns',
- items: {
- type: 'string',
- },
- },
- include_paths: {
- type: 'array',
- description: 'Only crawl URLs matching these patterns',
- items: {
- type: 'string',
- },
- },
- limit: {
- type: 'number',
- description: 'Maximum pages to crawl (default 10, max 50)',
- },
- max_depth: {
- type: 'number',
- description: 'How deep to follow links (default 2)',
- },
- url: {
- type: 'string',
- description: 'Starting URL to crawl from',
- },
- },
- required: ['url'],
- },
- resultSchema: undefined,
- },
- create_file: {
+ create_empty_file: {
parameters: {
type: 'object',
properties: {
@@ -1328,7 +1763,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- deploy_api: {
+ deploy_as_api: {
parameters: {
type: 'object',
properties: {
@@ -1382,7 +1817,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
deploymentType: {
type: 'string',
description:
- 'Deployment surface this result describes. For deploy_api and redeploy this is always "api".',
+ 'Deployment surface this result describes. For deploy_as_api and redeploy this is always "api".',
},
examples: {
type: 'object',
@@ -1412,7 +1847,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
],
},
},
- deploy_chat: {
+ deploy_as_chat: {
parameters: {
type: 'object',
properties: {
@@ -1526,7 +1961,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
deploymentType: {
type: 'string',
description:
- 'Deployment surface this result describes. For deploy_chat this is always "chat".',
+ 'Deployment surface this result describes. For deploy_as_chat this is always "chat".',
},
examples: {
type: 'object',
@@ -1547,7 +1982,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
success: {
type: 'boolean',
- description: 'Whether the deploy_chat action completed successfully.',
+ description: 'Whether the deploy_as_chat action completed successfully.',
},
version: {
type: 'number',
@@ -1571,134 +2006,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
],
},
},
- deploy_custom_block: {
- parameters: {
- type: 'object',
- properties: {
- action: {
- type: 'string',
- description: 'Whether to publish (deploy) or unpublish (undeploy) the custom block',
- enum: ['deploy', 'undeploy'],
- default: 'deploy',
- },
- description: {
- type: 'string',
- description: 'Short description shown in the block picker, max 280 characters',
- },
- exposedOutputs: {
- type: 'array',
- description:
- "Outputs the block exposes, each mapping a child block output path to a friendly name (use get_block_outputs for valid paths). Omit to expose the terminal block's whole result",
- items: {
- type: 'object',
- properties: {
- blockId: {
- type: 'string',
- description: 'Block UUID inside the workflow',
- },
- name: {
- type: 'string',
- description: 'Friendly output name shown on the block',
- },
- path: {
- type: 'string',
- description:
- "Dot-path into that block's output (from get_block_outputs relativeOutputs)",
- },
- },
- required: ['blockId', 'path', 'name'],
- },
- },
- iconUrl: {
- type: 'string',
- description:
- 'Optional icon image for the block: a workspace file VFS path (e.g. "files/icon.png", copied into public icon storage at publish) or an https image URL. Omit to use the organization\'s default icon',
- },
- inputs: {
- type: 'array',
- description:
- "Optional per-input placeholder overrides. Input names and types are derived from the workflow's input trigger and cannot be changed here",
- items: {
- type: 'object',
- properties: {
- id: {
- type: 'string',
- description: 'Stable id of the input trigger field',
- },
- placeholder: {
- type: 'string',
- description: "Placeholder text shown in the block's input field",
- },
- },
- required: ['id'],
- },
- },
- name: {
- type: 'string',
- description:
- 'Display name for the block, max 60 characters. REQUIRED the first time a workflow is published. When republishing an existing block, omit it to keep the current name or pass a new one to rename. Ignored for undeploy.',
- },
- workflowId: {
- type: 'string',
- description: 'Workflow ID (defaults to active workflow)',
- },
- },
- },
- resultSchema: {
- type: 'object',
- properties: {
- action: {
- type: 'string',
- description: 'Action performed by the tool, such as "deploy" or "undeploy".',
- },
- blockId: {
- type: 'string',
- description: 'Custom block record ID.',
- },
- blockType: {
- type: 'string',
- description: 'Stable block type slug (custom_block_*) used in workflow state.',
- },
- deploymentConfig: {
- type: 'object',
- description:
- "Structured deployment configuration keyed by surface name. Includes the block's type, name, description, icon, derived input fields, and exposed outputs.",
- },
- deploymentStatus: {
- type: 'object',
- description:
- 'Structured per-surface deployment status keyed by surface name, including customBlock and the underlying api surface when applicable.',
- },
- deploymentType: {
- type: 'string',
- description:
- 'Deployment surface this result describes. For deploy_custom_block this is always "custom_block".',
- },
- isDeployed: {
- type: 'boolean',
- description: 'Whether the custom block is published after this tool call.',
- },
- name: {
- type: 'string',
- description: 'Display name of the custom block.',
- },
- removed: {
- type: 'boolean',
- description: 'Whether the custom block was unpublished during an undeploy action.',
- },
- updated: {
- type: 'boolean',
- description: 'Whether an existing custom block was updated instead of created.',
- },
- workflowId: {
- type: 'string',
- description: 'Workflow ID the custom block is bound to.',
- },
- },
- required: ['deploymentType', 'deploymentStatus'],
- },
- },
- deploy_mcp: {
+ deploy_as_mcp: {
parameters: {
type: 'object',
properties: {
@@ -1773,7 +2081,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
deploymentType: {
type: 'string',
description:
- 'Deployment surface this result describes. For deploy_mcp this is always "mcp".',
+ 'Deployment surface this result describes. For deploy_as_mcp this is always "mcp".',
},
examples: {
type: 'object',
@@ -1844,7 +2152,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- download_to_workspace_file: {
+ download_file: {
parameters: {
type: 'object',
properties: {
@@ -1893,38 +2201,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- edit_content: {
- parameters: {
- type: 'object',
- properties: {
- content: {
- type: 'string',
- description:
- 'The text content to write. For append: text to append. For update: full replacement text. For patch with search_replace: the replacement text. For patch with anchored: the insert/replacement text.',
- },
- },
- required: ['content'],
- },
- resultSchema: {
- type: 'object',
- properties: {
- data: {
- type: 'object',
- description:
- 'Optional operation metadata such as file id, file name, size, and content type.',
- },
- message: {
- type: 'string',
- description: 'Human-readable summary of the outcome.',
- },
- success: {
- type: 'boolean',
- description: 'Whether the content was applied successfully.',
- },
- },
- required: ['success', 'message'],
- },
- },
edit_workflow: {
parameters: {
type: 'object',
@@ -1948,7 +2224,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
params: {
type: 'object',
description:
- 'Parameters for the operation (optional).\nFor edit: {"inputs": {"temperature": 0.5}} NOT {"subBlocks": {"temperature": {"value": 0.5}}}\nFor add: {"type": "agent", "name": "My Agent", "inputs": {"model": ""}}\nFor delete: omit params entirely (none needed)',
+ 'Parameters for the operation (optional).\nFor edit: {"inputs": {"temperature": 0.5}} NOT {"subBlocks": {"temperature": {"value": 0.5}}}\nFor add: {"type": "agent", "name": "My Agent", "inputs": {"model": ""}}\nFor delete: omit params entirely (none needed)\nBlock-level settings (retry, triggerMode, advancedMode) go beside "inputs", never inside it.',
},
},
required: ['operation_type', 'block_id'],
@@ -1964,49 +2240,48 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- enrichment_run: {
+ extensions: {
+ parameters: {
+ properties: {
+ request: {
+ description: 'What tool/skill/MCP action is needed.',
+ type: 'string',
+ },
+ sessionId: {
+ description:
+ 'Reusable session ID returned by an earlier extensions call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message. Omit it for a new or independent task.',
+ type: 'string',
+ },
+ title: {
+ description:
+ "Required private orchestration label (3–8 words) for this session's stable objective. Stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator; not shown to the extensions agent. When resuming with sessionId, copy the registry title unchanged.",
+ maxLength: 120,
+ type: 'string',
+ },
+ },
+ required: ['request', 'title'],
+ type: 'object',
+ },
+ resultSchema: undefined,
+ },
+ extract_doc_assets: {
parameters: {
type: 'object',
properties: {
- enrichmentId: {
+ destination: {
type: 'string',
description:
- "Which enrichment to run. Discover the full set and each one's inputs/outputs via user_table.list_enrichments.",
- enum: [
- 'work-email',
- 'phone-number',
- 'company-domain',
- 'company-info',
- 'email-verification',
- ],
+ 'Folder to write the extracted set into. Defaults to a "files/ assets" folder next to the source.',
},
- inputs: {
- type: 'object',
+ path: {
+ type: 'string',
description:
- 'Map of the enrichment\'s input id → value, e.g. { "fullName": "Jane Doe", "companyDomain": "acme.com" }. Provide a value for every required input.',
+ 'Workspace VFS path of the source document, e.g. files/Brand%20Deck.pptx. Must be an existing .pptx, .docx, or .pdf file.',
},
},
- required: ['enrichmentId', 'inputs'],
- },
- resultSchema: {
- type: 'object',
- properties: {
- matched: {
- type: 'boolean',
- description: 'True when a provider returned a non-empty result.',
- },
- provider: {
- type: ['string', 'null'],
- description:
- 'Internal label of the provider that produced the result (billing/diagnostics only — do NOT surface it to the user), or null on no match.',
- },
- result: {
- type: 'object',
- description: 'Mapped output values from the winning provider (empty object on no match).',
- },
- },
- required: ['matched', 'result'],
+ required: ['path'],
},
+ resultSchema: undefined,
},
ffmpeg: {
parameters: {
@@ -2198,158 +2473,20 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
"Optional brief instruction (one short sentence) to scope the task. The agent inherits the full conversation history — do NOT restate or rewrite conversation content, only add scoping the history doesn't convey.",
type: 'string',
},
- },
- type: 'object',
- },
- resultSchema: undefined,
- },
- function_execute: {
- parameters: {
- type: 'object',
- properties: {
- code: {
+ sessionId: {
+ description:
+ 'Reusable session ID returned by an earlier file call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message — the agent resumes from its saved transcript and receives unseen parent conversation messages. Omit it for a new or independent task.',
type: 'string',
- description:
- 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Use each needed secret as {{VAR_NAME}}; the reference resolves to the value exactly as stored.',
- },
- inputs: {
- type: 'object',
- description:
- 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.',
- properties: {
- directories: {
- type: 'array',
- description:
- 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.',
- items: {
- type: 'object',
- properties: {
- path: {
- type: 'string',
- description:
- 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/{path}".',
- },
- sandboxPath: {
- type: 'string',
- description:
- 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.',
- },
- },
- required: ['path'],
- },
- },
- files: {
- type: 'array',
- description: 'Workspace files to mount into the sandbox.',
- items: {
- type: 'object',
- properties: {
- path: {
- type: 'string',
- description:
- 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".',
- },
- sandboxPath: {
- type: 'string',
- description:
- 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.',
- },
- },
- required: ['path'],
- },
- },
- tables: {
- type: 'array',
- description: 'Workspace tables to mount as CSV files.',
- items: {
- type: 'object',
- properties: {
- path: {
- type: 'string',
- description: 'Canonical VFS table path when available.',
- },
- sandboxPath: {
- type: 'string',
- description: 'Optional full sandbox path for the mounted CSV.',
- },
- tableId: {
- type: 'string',
- description: 'Workspace table ID.',
- },
- },
- },
- },
- },
- },
- language: {
- type: 'string',
- description: 'Execution language.',
- enum: ['javascript', 'python', 'shell'],
- },
- outputTable: {
- type: 'string',
- description:
- 'Table ID to overwrite with the code\'s return value. Code MUST return an array of objects where keys match column names. All existing rows are replaced. Example: "tbl_abc123"',
- },
- outputs: {
- type: 'object',
- description:
- 'Workspace files to create or overwrite from returned code results or sandbox-created files.',
- properties: {
- files: {
- type: 'array',
- description:
- 'File outputs. Missing parent folders are created automatically for create mode.',
- items: {
- type: 'object',
- properties: {
- format: {
- type: 'string',
- description: 'Optional serialization format for returned values.',
- enum: ['json', 'csv', 'txt', 'md', 'html'],
- },
- mimeType: {
- type: 'string',
- description: 'Optional MIME type override when inference is not enough.',
- },
- mode: {
- type: 'string',
- description: 'Create a new file or overwrite an existing file at path.',
- enum: ['create', 'overwrite'],
- },
- path: {
- type: 'string',
- description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".',
- },
- sandboxPath: {
- type: 'string',
- description:
- 'Optional full path to a file created inside the sandbox. Omit to save the code return value.',
- },
- },
- required: ['path', 'mode'],
- },
- },
- },
- },
- sandboxId: {
- type: 'string',
- description:
- 'Optional Sim sandbox id from agent/sandboxes/{name}.json. DEFAULT-FIRST: omit this whenever the documented default function_execute environment can do the job. Select a ready existing Sim sandbox only when a required third-party dependency, Debian system package, or managed CLI is known to be absent, or a default attempt failed specifically because it was missing. Never guess an id.',
- },
- timeout: {
- type: 'number',
- description:
- 'Maximum execution time in SECONDS (Sim converts to milliseconds). The sandbox stops execution and returns a timeout error after this duration. Defaults to 10 seconds and is capped at 300 seconds regardless of plan.',
- default: 10,
},
title: {
- type: 'string',
description:
- 'Short user-visible label for this execution, e.g. "Clean customer CSV", "Revenue chart", or "Query GitHub issues".',
+ "Required private orchestration label (3–8 words) for this session's stable objective. Stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator; not shown to the file agent. When resuming with sessionId, copy the registry title unchanged.",
+ maxLength: 120,
+ type: 'string',
},
},
- required: ['code'],
+ required: ['title'],
+ type: 'object',
},
resultSchema: undefined,
},
@@ -2877,54 +3014,18 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- get_deployment_log: {
+ get_deployment_status: {
parameters: {
type: 'object',
properties: {
workflowId: {
type: 'string',
- description:
- 'Optional workflow ID. If not provided, uses the current workflow in context.',
+ description: 'Workflow ID to check (defaults to current workflow)',
},
},
},
resultSchema: undefined,
},
- get_page_contents: {
- parameters: {
- type: 'object',
- properties: {
- include_highlights: {
- type: 'boolean',
- description: 'Include key highlights (default false)',
- },
- include_summary: {
- type: 'boolean',
- description: 'Include AI-generated summary (default false)',
- },
- include_text: {
- type: 'boolean',
- description: 'Include full page text (default true)',
- },
- urls: {
- type: 'array',
- description: 'URLs to get content from (max 10)',
- items: {
- type: 'string',
- },
- },
- },
- required: ['urls'],
- },
- resultSchema: undefined,
- },
- get_platform_actions: {
- parameters: {
- type: 'object',
- properties: {},
- },
- resultSchema: undefined,
- },
get_workflow_data: {
parameters: {
type: 'object',
@@ -3007,12 +3108,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
path: {
type: 'string',
description:
- "Optional scope. A prefix (e.g. 'workflows/', 'environment/', 'internal/') searches the VFS map under it. An exact single-file path under files/ or uploads/ (optionally with /content) searches that file's content only; folders and multi-file trees are rejected for content search.",
+ "Optional scope. A prefix (e.g. 'workflows/', 'environment/', 'internal/') searches the VFS map under it. An exact supported single-file path searches that file's content; folders and multi-file trees are rejected for content search.",
},
pattern: {
type: 'string',
description:
- "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, memories) by default; searches a single file's extracted text when path is one files/ or uploads/ file leaf.",
+ "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, memories) by default, or an exact supported file leaf's content when path selects one.",
},
toolTitle: {
type: 'string',
@@ -3024,6 +3125,24 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
+ interrupt_agent: {
+ parameters: {
+ type: 'object',
+ properties: {
+ agent_id: {
+ type: 'string',
+ description: 'The agent id to interrupt.',
+ },
+ reason: {
+ type: 'string',
+ description:
+ "Why you are stopping it, in a few words. Recorded in the agent's final status.",
+ },
+ },
+ required: ['agent_id'],
+ },
+ resultSchema: undefined,
+ },
knowledge: {
parameters: {
properties: {
@@ -3031,204 +3150,35 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
description: 'What knowledge base action is needed.',
type: 'string',
},
+ sessionId: {
+ description:
+ 'Reusable session ID returned by an earlier knowledge call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message. Omit it for a new or independent task.',
+ type: 'string',
+ },
+ title: {
+ description:
+ "Required private orchestration label (3–8 words) for this session's stable objective. Stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator; not shown to the knowledge agent. When resuming with sessionId, copy the registry title unchanged.",
+ maxLength: 120,
+ type: 'string',
+ },
},
- required: ['request'],
+ required: ['request', 'title'],
type: 'object',
},
resultSchema: undefined,
},
- knowledge_base: {
+ list_deployment_versions: {
parameters: {
type: 'object',
properties: {
- args: {
- type: 'object',
- description: 'Arguments for the operation',
- properties: {
- apiKey: {
- type: 'string',
- description:
- 'API key for API-key-based connectors (required when connector auth mode is apiKey)',
- },
- chunkingConfig: {
- type: 'object',
- description: "Chunking configuration (optional for 'create')",
- properties: {
- maxSize: {
- type: 'number',
- description: 'Maximum chunk size (100-4000, default: 1024)',
- default: 1024,
- },
- minSize: {
- type: 'number',
- description: 'Minimum chunk size (1-2000, default: 1)',
- default: 1,
- },
- overlap: {
- type: 'number',
- description: 'Overlap between chunks (0-500, default: 200)',
- default: 200,
- },
- },
- },
- connectorId: {
- type: 'string',
- description:
- 'Connector ID (required for update_connector, delete_connector, sync_connector)',
- },
- connectorStatus: {
- type: 'string',
- description: 'Connector status (optional for update_connector)',
- enum: ['active', 'paused'],
- },
- connectorType: {
- type: 'string',
- description:
- "Connector type from registry, e.g. 'confluence', 'google_drive', 'notion' (required for add_connector). Read knowledgebases/connectors/{type}.json for the config schema.",
- },
- credentialId: {
- type: 'string',
- description:
- 'OAuth credential ID from environment/credentials.json (required for OAuth connectors)',
- },
- description: {
- type: 'string',
- description: "Description of the knowledge base (optional for 'create')",
- },
- disabledTagIds: {
- type: 'array',
- description:
- 'Tag definition IDs to opt out of (optional for add_connector). See tagDefinitions in the connector schema.',
- },
- documentId: {
- type: 'string',
- description: 'Document ID (required for update_document)',
- },
- documentIds: {
- type: 'array',
- description: 'Document IDs (for batch delete_document)',
- items: {
- type: 'string',
- },
- },
- enabled: {
- type: 'boolean',
- description: 'Enable/disable a document (optional for update_document)',
- },
- filePaths: {
- type: 'array',
- description:
- 'Canonical workspace file VFS paths to add as documents (for add_file), e.g. ["files/Docs/handbook.pdf"].',
- items: {
- type: 'string',
- },
- },
- filename: {
- type: 'string',
- description: 'New filename for a document (optional for update_document)',
- },
- knowledgeBaseId: {
- type: 'string',
- description:
- 'Knowledge base ID (required for get, query, add_file, list_tags, create_tag, get_tag_usage)',
- },
- knowledgeBaseIds: {
- type: 'array',
- description: 'Knowledge base IDs (for batch delete)',
- items: {
- type: 'string',
- },
- },
- name: {
- type: 'string',
- description: "Name of the knowledge base (required for 'create')",
- },
- query: {
- type: 'string',
- description: "Search query text (required for 'query')",
- },
- sourceConfig: {
- type: 'object',
- description:
- 'Connector-specific configuration matching the configFields in knowledgebases/connectors/{type}.json',
- },
- syncIntervalMinutes: {
- type: 'number',
- description:
- 'Sync interval in minutes. Accepted values: 60 (hourly), 360 (6h), 1440 (daily), 10080 (weekly), 0 (manual only). Default: 1440',
- default: 1440,
- },
- tagDefinitionId: {
- type: 'string',
- description: 'Tag definition ID (required for update_tag, delete_tag)',
- },
- tagDisplayName: {
- type: 'string',
- description:
- 'Display name for the tag (required for create_tag, optional for update_tag)',
- },
- tagFieldType: {
- type: 'string',
- description:
- 'Field type: text, number, date, boolean (optional for create_tag, defaults to text)',
- enum: ['text', 'number', 'date', 'boolean'],
- },
- topK: {
- type: 'number',
- description: 'Number of results to return (1-50, default: 5)',
- default: 5,
- },
- workspaceId: {
- type: 'string',
- description:
- "Workspace ID. Required for 'create' when there is no workspace in context; otherwise the current workspace context is used.",
- },
- },
- },
- operation: {
+ workflowId: {
type: 'string',
- description: 'The operation to perform',
- enum: [
- 'create',
- 'get',
- 'query',
- 'add_file',
- 'update',
- 'delete_document',
- 'update_document',
- 'list_tags',
- 'create_tag',
- 'update_tag',
- 'delete_tag',
- 'get_tag_usage',
- 'add_connector',
- 'update_connector',
- 'delete_connector',
- 'sync_connector',
- ],
- },
- },
- required: ['operation'],
- },
- resultSchema: {
- type: 'object',
- properties: {
- data: {
- type: ['object', 'array'],
description:
- 'Operation-specific result payload. An object for most operations; list_tags and get_tag_usage return an array of tag definitions.',
- },
- message: {
- type: 'string',
- description: 'Human-readable outcome summary.',
- },
- success: {
- type: 'boolean',
- description: 'Whether the operation succeeded.',
+ 'Optional workflow ID. If not provided, uses the current workflow in context.',
},
},
- required: ['success', 'message'],
},
+ resultSchema: undefined,
},
list_integration_tools: {
parameters: {
@@ -3244,13 +3194,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- list_user_workspaces: {
- parameters: {
- type: 'object',
- properties: {},
- },
- resultSchema: undefined,
- },
list_workspace_mcp_servers: {
parameters: {
type: 'object',
@@ -3423,7 +3366,220 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- manage_mcp_tool: {
+ manage_knowledge_base: {
+ parameters: {
+ type: 'object',
+ properties: {
+ args: {
+ type: 'object',
+ description: 'Arguments for the operation',
+ properties: {
+ apiKey: {
+ type: 'string',
+ description:
+ 'API key for API-key-based connectors (required when connector auth mode is apiKey). Accepts an environment-variable reference — {{NAME}} — resolved server-side from workspace/user environment variables; a raw key also works.',
+ },
+ chunkingConfig: {
+ type: 'object',
+ description: "Chunking configuration (optional for 'create')",
+ properties: {
+ maxSize: {
+ type: 'number',
+ description: 'Maximum chunk size (100-4000, default: 1024)',
+ default: 1024,
+ },
+ minSize: {
+ type: 'number',
+ description: 'Minimum chunk size (1-2000, default: 100)',
+ default: 1,
+ },
+ overlap: {
+ type: 'number',
+ description: 'Overlap between chunks (0-500, default: 200)',
+ default: 200,
+ },
+ },
+ },
+ connectorId: {
+ type: 'string',
+ description:
+ 'Connector ID (required for update_connector, delete_connector, sync_connector)',
+ },
+ connectorStatus: {
+ type: 'string',
+ description: 'Connector status (optional for update_connector)',
+ enum: ['active', 'paused'],
+ },
+ connectorType: {
+ type: 'string',
+ description:
+ "Connector type from registry, e.g. 'confluence', 'google_drive', 'notion' (required for add_connector). Read knowledgebases/connectors/{type}.json for the config schema.",
+ },
+ credentialId: {
+ type: 'string',
+ description:
+ 'OAuth credential ID from environment/credentials.json (required for OAuth connectors)',
+ },
+ description: {
+ type: 'string',
+ description: "Description of the knowledge base (optional for 'create')",
+ },
+ disabledTagIds: {
+ type: 'array',
+ description:
+ 'Tag definition IDs to opt out of (optional for add_connector). See tagDefinitions in the connector schema.',
+ },
+ documentId: {
+ type: 'string',
+ description: 'Document ID (required for update_document)',
+ },
+ documentIds: {
+ type: 'array',
+ description: 'Document IDs (for batch delete_document)',
+ items: {
+ type: 'string',
+ },
+ },
+ enabled: {
+ type: 'boolean',
+ description: 'Enable/disable a document (optional for update_document)',
+ },
+ filePaths: {
+ type: 'array',
+ description:
+ 'Canonical workspace file VFS paths to add as documents (for add_file), e.g. ["files/Docs/handbook.pdf"].',
+ items: {
+ type: 'string',
+ },
+ },
+ filename: {
+ type: 'string',
+ description: 'New filename for a document (optional for update_document)',
+ },
+ knowledgeBaseId: {
+ type: 'string',
+ description:
+ 'Knowledge base ID (required for get, query, add_file, list_tags, create_tag, get_tag_usage)',
+ },
+ knowledgeBaseIds: {
+ type: 'array',
+ description: 'Knowledge base IDs (for batch delete)',
+ items: {
+ type: 'string',
+ },
+ },
+ name: {
+ type: 'string',
+ description: "Name of the knowledge base (required for 'create')",
+ },
+ query: {
+ type: 'string',
+ description: "Search query text (required for 'query')",
+ },
+ sourceConfig: {
+ type: 'object',
+ description:
+ 'Connector-specific configuration matching the configFields in knowledgebases/connectors/{type}.json',
+ },
+ syncIntervalMinutes: {
+ type: 'number',
+ description:
+ 'Sync interval in minutes. Accepted values: 60 (hourly), 360 (6h), 1440 (daily), 10080 (weekly), 0 (manual only). Default: 1440',
+ default: 1440,
+ },
+ tagDefinitionId: {
+ type: 'string',
+ description: 'Tag definition ID (required for update_tag, delete_tag)',
+ },
+ tagDisplayName: {
+ type: 'string',
+ description:
+ 'Display name for the tag (required for create_tag, optional for update_tag)',
+ },
+ tagFieldType: {
+ type: 'string',
+ description:
+ 'Field type: text, number, date, boolean (optional for create_tag, defaults to text)',
+ enum: ['text', 'number', 'date', 'boolean'],
+ },
+ tagValues: {
+ type: 'array',
+ description:
+ 'Typed tag values to set on this document (optional for update_document). Resolve tagDefinitionId with list_tags first. Use null to clear a value.',
+ items: {
+ type: 'object',
+ properties: {
+ tagDefinitionId: {
+ type: 'string',
+ description: 'Tag definition ID returned by list_tags.',
+ },
+ value: {
+ type: ['string', 'number', 'boolean', 'null'],
+ description:
+ "Value matching the tag definition's field type: string for text, number for number, YYYY-MM-DD string for date, boolean for boolean, or null to clear.",
+ },
+ },
+ required: ['tagDefinitionId', 'value'],
+ },
+ },
+ topK: {
+ type: 'number',
+ description: 'Number of results to return (1-100, default: 5)',
+ default: 5,
+ },
+ workspaceId: {
+ type: 'string',
+ description:
+ "Workspace ID. Required for 'create' when there is no workspace in context; otherwise the current workspace context is used.",
+ },
+ },
+ },
+ operation: {
+ type: 'string',
+ description: 'The operation to perform',
+ enum: [
+ 'create',
+ 'get',
+ 'query',
+ 'add_file',
+ 'update',
+ 'delete_document',
+ 'update_document',
+ 'list_tags',
+ 'create_tag',
+ 'update_tag',
+ 'delete_tag',
+ 'get_tag_usage',
+ 'add_connector',
+ 'update_connector',
+ 'delete_connector',
+ 'sync_connector',
+ ],
+ },
+ },
+ required: ['operation'],
+ },
+ resultSchema: {
+ type: 'object',
+ properties: {
+ data: {
+ type: ['object', 'array'],
+ description:
+ 'Operation-specific result payload. An object for most operations; list_tags and get_tag_usage return an array of tag definitions.',
+ },
+ message: {
+ type: 'string',
+ description: 'Human-readable outcome summary.',
+ },
+ success: {
+ type: 'boolean',
+ description: 'Whether the operation succeeded.',
+ },
+ },
+ required: ['success', 'message'],
+ },
+ },
+ manage_mcp_connection: {
parameters: {
type: 'object',
properties: {
@@ -3437,7 +3593,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
headers: {
type: 'object',
- description: 'Optional HTTP headers to send with requests (key-value pairs)',
+ description:
+ 'Optional HTTP headers to send with requests (key-value pairs). Values accept {{ENV_VAR}} references, resolved per-user at connect time — prefer them over pasting raw tokens.',
},
name: {
type: 'string',
@@ -3562,30 +3719,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- materialize_file: {
- parameters: {
- type: 'object',
- properties: {
- fileNames: {
- type: 'array',
- description:
- 'The names of the uploaded files to materialize (e.g. ["report.pdf", "data.csv"])',
- items: {
- type: 'string',
- },
- },
- operation: {
- type: 'string',
- description:
- 'What to do with the file. "save" promotes it to a permanent files/ path. "import" imports a workflow JSON as a workspace workflow. "extract" decompresses a .zip upload into files/