diff --git a/apps/docs/components/workflow-preview/format-references.tsx b/apps/docs/components/workflow-preview/format-references.tsx
index 6a3c01ae21e..15bfeb5c049 100644
--- a/apps/docs/components/workflow-preview/format-references.tsx
+++ b/apps/docs/components/workflow-preview/format-references.tsx
@@ -15,12 +15,10 @@ export function formatReferences(text: string): ReactNode[] {
const isReference =
(part.startsWith('<') && part.endsWith('>')) || (part.startsWith('{{') && part.endsWith('}}'))
return isReference ? (
- // biome-ignore lint/suspicious/noArrayIndexKey: static, never reordered
{part}
) : (
- // biome-ignore lint/suspicious/noArrayIndexKey: static, never reordered
{part}
)
})
diff --git a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx
index 91ebc37ea42..2d153a83769 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx
@@ -22,7 +22,7 @@ import { useSubmitCopilotFeedback } from '@/hooks/queries/copilot-feedback'
import { useForkMothershipChat } from '@/hooks/queries/mothership-chats'
import { useFolderStore } from '@/stores/folders/store'
-const SPECIAL_TAGS = 'thinking|options|usage_upgrade|credential|mothership-error|file'
+const SPECIAL_TAGS = 'thinking|options|usage_upgrade|credential|mothership-error|file|question'
function toPlainText(raw: string): string {
return (
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
index afeac25cc3b..eac918b2bbd 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
@@ -279,6 +279,8 @@ const MARKDOWN_COMPONENTS = {
interface ChatContentProps {
content: string
isStreaming?: boolean
+ /** Transcript-derived answers for this message's question card (renders the recap). */
+ questionAnswers?: string[]
onOptionSelect?: (id: string) => void
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
onRevealStateChange?: (isRevealing: boolean) => void
@@ -287,6 +289,7 @@ interface ChatContentProps {
function ChatContentInner({
content,
isStreaming = false,
+ questionAnswers,
onOptionSelect,
onWorkspaceResourceSelect,
onRevealStateChange,
@@ -406,6 +409,7 @@ function ChatContentInner({
)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts
index f211a5f42e5..8151e32f11c 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts
@@ -2,4 +2,5 @@ export type { AgentGroupItem, NestedAgentGroup } from './agent-group'
export { AgentGroup, CircleStop, isAgentGroupResolved } from './agent-group'
export { ChatContent } from './chat-content'
export { Options } from './options'
+export { QuestionDisplay } from './question'
export { PendingTagIndicator, parseSpecialTags, SpecialTags } from './special-tags'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/index.ts
new file mode 100644
index 00000000000..5272577cfda
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/index.ts
@@ -0,0 +1,5 @@
+export {
+ formatQuestionAnswerMessage,
+ parseQuestionAnswerMessage,
+ QuestionDisplay,
+} from './question'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts
new file mode 100644
index 00000000000..f3d7b9528ea
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts
@@ -0,0 +1,86 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import {
+ formatQuestionAnswerMessage,
+ parseQuestionAnswerMessage,
+} from '@/app/workspace/[workspaceId]/home/components/message-content/components/question/question'
+import type { QuestionItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags'
+
+const QUESTIONS: QuestionItem[] = [
+ {
+ type: 'single_select',
+ prompt: 'How should I handle the duplicates?',
+ options: [{ id: 'keep_newest', label: 'Keep the newest entry' }],
+ },
+ {
+ type: 'single_select',
+ prompt: 'Delete 4 archived workflows?',
+ options: [
+ { id: 'yes', label: 'Delete them' },
+ { id: 'no', label: 'Cancel' },
+ ],
+ },
+ {
+ type: 'multi_select',
+ prompt: 'What time zone should the daily report run in?',
+ options: [
+ { id: 'est', label: 'EST' },
+ { id: 'pst', label: 'PST' },
+ ],
+ },
+]
+
+describe('formatQuestionAnswerMessage', () => {
+ it('sends a prompt-answer line for a single question', () => {
+ expect(formatQuestionAnswerMessage([QUESTIONS[0]], ['Keep the newest entry'])).toBe(
+ 'How should I handle the duplicates? — Keep the newest entry'
+ )
+ })
+
+ it('sends one prompt-answer line per question for multi-step batches', () => {
+ expect(formatQuestionAnswerMessage(QUESTIONS, ['Keep the newest entry', 'Cancel', 'EST'])).toBe(
+ 'How should I handle the duplicates? — Keep the newest entry\n' +
+ 'Delete 4 archived workflows? — Cancel\n' +
+ 'What time zone should the daily report run in? — EST'
+ )
+ })
+})
+
+describe('parseQuestionAnswerMessage', () => {
+ it('round-trips what formatQuestionAnswerMessage produces', () => {
+ const answers = ['Keep the newest entry', 'Cancel', 'EST, PST']
+ const message = formatQuestionAnswerMessage(QUESTIONS, answers)
+ expect(parseQuestionAnswerMessage(QUESTIONS, message)).toEqual(answers)
+ })
+
+ it('round-trips a single question', () => {
+ const message = formatQuestionAnswerMessage([QUESTIONS[0]], ['Merge them'])
+ expect(parseQuestionAnswerMessage([QUESTIONS[0]], message)).toEqual(['Merge them'])
+ })
+
+ it('rejects an unrelated user message (dismissed card, typed something else)', () => {
+ expect(parseQuestionAnswerMessage([QUESTIONS[0]], 'actually, show me the logs')).toBeNull()
+ })
+
+ it('rejects when the line count does not match the question count', () => {
+ const partial = formatQuestionAnswerMessage(QUESTIONS.slice(0, 2), ['A', 'B'])
+ expect(parseQuestionAnswerMessage(QUESTIONS, partial)).toBeNull()
+ })
+
+ it('rejects when a line pairs with the wrong prompt', () => {
+ const swapped =
+ 'Delete 4 archived workflows? — Cancel\n' +
+ 'How should I handle the duplicates? — Keep the newest entry\n' +
+ 'What time zone should the daily report run in? — EST'
+ expect(parseQuestionAnswerMessage(QUESTIONS, swapped)).toBeNull()
+ })
+
+ it('preserves em-dashes inside the answer text', () => {
+ const message = formatQuestionAnswerMessage([QUESTIONS[0]], ['newest — but keep backups'])
+ expect(parseQuestionAnswerMessage([QUESTIONS[0]], message)).toEqual([
+ 'newest — but keep backups',
+ ])
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx
new file mode 100644
index 00000000000..f3e19b0ef3d
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx
@@ -0,0 +1,478 @@
+'use client'
+
+import { useState } from 'react'
+import {
+ ArrowRight,
+ Button,
+ Check,
+ checkboxIconVariants,
+ checkboxVariants,
+ ChevronLeft,
+ ChevronRight,
+ cn,
+ X,
+} from '@sim/emcn'
+import type { QuestionItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
+
+/**
+ * Builds the single user message sent after the final question is answered:
+ * one `Prompt — Answer` line per question, for lone questions too. The uniform
+ * shape is what lets the chat pair this message back to its question card
+ * (see parseQuestionAnswerMessage) and render the card as the user turn
+ * instead of echoing a duplicate bubble.
+ */
+export function formatQuestionAnswerMessage(questions: QuestionItem[], answers: string[]): string {
+ return questions.map((q, i) => `${q.prompt} — ${answers[i] ?? ''}`).join('\n')
+}
+
+/**
+ * Strictly matches a user message against a question batch's answer format:
+ * exactly one `Prompt — Answer` line per question, in order. Returns the
+ * answers, or null when the message is not this batch's answer — a dismissed
+ * card followed by an unrelated typed message must not match.
+ */
+export function parseQuestionAnswerMessage(
+ questions: QuestionItem[],
+ content: string
+): string[] | null {
+ const lines = content.split('\n')
+ if (lines.length !== questions.length) return null
+ const answers: string[] = []
+ for (const [i, question] of questions.entries()) {
+ const prefix = `${question.prompt} — `
+ if (!lines[i].startsWith(prefix)) return null
+ answers.push(lines[i].slice(prefix.length))
+ }
+ return answers
+}
+
+const OPTION_ROW_CLASSES =
+ 'flex items-center gap-2 border-[var(--divider)] px-2 py-2 text-left transition-colors'
+
+/** Ghost icon-button chrome shared by the stepper chevrons and the dismiss X. */
+const ICON_BUTTON_CLASSES = 'relative size-[14px] flex-shrink-0 p-0'
+
+/** Leading number slot matching the suggested follow-ups rows. */
+function RowNumber({ value }: { value: number }) {
+ return (
+
+ {value}
+
+ )
+}
+
+/**
+ * Leading checkbox slot for multi_select rows. Purely presentational — it
+ * reuses the emcn Checkbox chrome via its exported variants, but the row
+ * button (or the free-text input) owns the interaction, so nesting a real
+ * Radix checkbox button inside the row button is avoided.
+ */
+function RowCheckbox({ checked, disabled }: { checked: boolean; disabled?: boolean }) {
+ return (
+
+
+ {checked && (
+
+ )}
+
+
+ )
+}
+
+type QuestionPhase = 'active' | 'answered' | 'dismissed'
+
+interface QuestionDisplayProps {
+ data: QuestionItem[]
+ /**
+ * Answers resolved from the transcript (the paired user message that
+ * answered this card). When present the card renders as the answered recap
+ * — it IS the user turn; the paired message bubble is hidden by the chat.
+ */
+ answers?: string[]
+ /** Sends the combined answer as a user message; undefined renders the div inert. */
+ onSelect?: (message: string) => void
+}
+
+/**
+ * Inline renderer for the `` special tag: a chat-inline div with the
+ * user input's chrome, the current question's prompt at the top left, dismiss
+ * (and a `‹ N of M ›` stepper for multi-step batches) at the top right, and
+ * suggested-action option rows beneath, always followed by a "Something else"
+ * row that reads as a plain option until clicked and then becomes the focused
+ * text box. `single_select` answers and advances on click (or on submitting
+ * typed text); `multi_select` rows toggle checkboxes and an option-styled
+ * Submit row confirms the step. Answering the last question sends one
+ * combined user message and collapses the div to a question/answer recap.
+ */
+export function QuestionDisplay({
+ data,
+ answers: transcriptAnswers,
+ onSelect,
+}: QuestionDisplayProps) {
+ const disabled = !onSelect
+ const [phase, setPhase] = useState('active')
+ const [step, setStep] = useState(0)
+ const [selectedByStep, setSelectedByStep] = useState(() => data.map(() => []))
+ const [customByStep, setCustomByStep] = useState(() => data.map(() => ''))
+ const [freeText, setFreeText] = useState('')
+ // The "Something else" row reads as a plain option until clicked, then
+ // becomes the focused text box (and reverts when left empty).
+ const [freeTextEditing, setFreeTextEditing] = useState(false)
+ // multi_select only: whether the typed "Something else" text is included in
+ // the answer. Unchecking keeps the text; it just stops counting.
+ const [customCheckedByStep, setCustomCheckedByStep] = useState(() =>
+ data.map(() => false)
+ )
+
+ // The typed text that actually joins a step's answer: multi_select customs
+ // only count while checked; single_select customs always count.
+ const customFor = (i: number, customs: string[]): string =>
+ data[i].type === 'multi_select' && !(customCheckedByStep[i] ?? false) ? '' : (customs[i] ?? '')
+
+ const containerClasses =
+ 'rounded-2xl border border-[var(--border-1)] bg-[var(--white)] px-2.5 py-2 dark:bg-[var(--surface-4)]'
+
+ // Transcript answers win over local state: they survive reloads (local
+ // phase does not) and keep live + rehydrated renders identical.
+ const localAnswers =
+ phase === 'answered'
+ ? data.map((question, i) =>
+ answerFor(question, selectedByStep[i] ?? [], customFor(i, customByStep))
+ )
+ : null
+ const recapAnswers = transcriptAnswers ?? localAnswers
+ if (data.length > 0 && recapAnswers) {
+ return (
+
+ {data.map((question, i) => (
+
+
{question.prompt}
+
{recapAnswers[i]}
+
+ ))}
+
+ )
+ }
+
+ if (data.length === 0 || phase === 'dismissed') return null
+
+ const question = data[step]
+ const isLast = step === data.length - 1
+ const options = question.options
+ const selected = selectedByStep[step] ?? []
+ const isMulti = question.type === 'multi_select'
+
+ const commitCustom = (): string[] => {
+ const next = [...customByStep]
+ next[step] = freeText.trim()
+ setCustomByStep(next)
+ return next
+ }
+
+ const goToStep = (next: number) => {
+ commitCustom()
+ setStep(next)
+ const prefill = customByStep[next] ?? ''
+ setFreeText(prefill)
+ setFreeTextEditing(prefill.trim().length > 0)
+ }
+
+ const finishStep = (selections: string[][], customs: string[]) => {
+ if (!isLast) {
+ setStep(step + 1)
+ const prefill = customs[step + 1] ?? ''
+ setFreeText(prefill)
+ setFreeTextEditing(prefill.trim().length > 0)
+ return
+ }
+ setPhase('answered')
+ onSelect?.(
+ formatQuestionAnswerMessage(
+ data,
+ data.map((q, i) => answerFor(q, selections[i] ?? [], customFor(i, customs)))
+ )
+ )
+ }
+
+ const handleSingleSelect = (label: string) => {
+ const selections = [...selectedByStep]
+ selections[step] = [label]
+ setSelectedByStep(selections)
+ const customs = [...customByStep]
+ customs[step] = ''
+ setCustomByStep(customs)
+ setFreeText('')
+ finishStep(selections, customs)
+ }
+
+ const handleMultiToggle = (label: string) => {
+ const selections = [...selectedByStep]
+ const current = selections[step] ?? []
+ selections[step] = current.includes(label)
+ ? current.filter((l) => l !== label)
+ : [...current, label]
+ setSelectedByStep(selections)
+ }
+
+ /** multi_select confirm: commits selections and/or typed text, then advances. */
+ const submitMultiStep = () => {
+ finishStep(selectedByStep, commitCustom())
+ }
+
+ /** Sets whether the typed "Something else" text counts — never touches the text. */
+ const setCustomChecked = (checked: boolean) => {
+ const next = [...customCheckedByStep]
+ next[step] = checked
+ setCustomCheckedByStep(next)
+ }
+
+ /** single_select free-text arrow: the typed text IS the answer. */
+ const submitSingleFreeText = () => {
+ const customs = commitCustom()
+ const selections = [...selectedByStep]
+ selections[step] = []
+ setSelectedByStep(selections)
+ finishStep(selections, customs)
+ }
+
+ const stepAnswered = (i: number): boolean => {
+ if ((selectedByStep[i]?.length ?? 0) > 0) return true
+ const text = i === step ? freeText : (customByStep[i] ?? '')
+ if (text.trim().length === 0) return false
+ return data[i].type === 'multi_select' ? (customCheckedByStep[i] ?? false) : true
+ }
+
+ const canSubmitStep = !disabled && (isMulti ? stepAnswered(step) : freeText.trim().length > 0)
+
+ return (
+
+
+
+ {question.prompt}
+
+
+ {data.length > 1 && (
+
+ goToStep(step - 1)}
+ disabled={step === 0}
+ className={cn(
+ ICON_BUTTON_CLASSES,
+ 'before:absolute before:inset-[-8px] before:content-[""] disabled:opacity-50'
+ )}
+ >
+
+ Previous question
+
+
+ {step + 1} of {data.length}
+
+ goToStep(step + 1)}
+ // Inert renders (older messages) browse freely; interactive ones
+ // gate forward movement on the current question being answered.
+ disabled={isLast || (!disabled && !stepAnswered(step))}
+ className={cn(
+ ICON_BUTTON_CLASSES,
+ 'before:absolute before:inset-[-8px] before:content-[""] disabled:opacity-50'
+ )}
+ >
+
+ Next question
+
+
+ )}
+ {!disabled && (
+
setPhase('dismissed')}
+ className={cn(
+ ICON_BUTTON_CLASSES,
+ 'before:absolute before:inset-[-14px] before:content-[""]'
+ )}
+ >
+
+ Dismiss
+
+ )}
+
+
+
+ {options.map((option, i) => {
+ const isSelected = selected.includes(option.label)
+ return (
+
+ isMulti ? handleMultiToggle(option.label) : handleSingleSelect(option.label)
+ }
+ className={cn(
+ OPTION_ROW_CLASSES,
+ disabled ? 'cursor-not-allowed' : 'hover-hover:bg-[var(--surface-5)]',
+ i > 0 && 'border-t',
+ isSelected && 'bg-[var(--surface-5)]'
+ )}
+ >
+ {isMulti ? (
+
+ ) : (
+
+ )}
+
+ {option.label}
+
+ {!isMulti && }
+
+ )
+ })}
+ {freeTextEditing ? (
+
0 && 'border-t')}>
+ {isMulti ? (
+ // Checked from the moment the row is clicked into; blur with
+ // nothing typed reverts to the plain option row. A real button
+ // (the editing row is a div, so no nesting hazard) so the box
+ // can be toggled even after typing — unchecking keeps the text,
+ // it just stops counting toward the answer.
+
+ setCustomChecked(!(customCheckedByStep[step] ?? false))}
+ data-state={(customCheckedByStep[step] ?? false) ? 'checked' : 'unchecked'}
+ data-disabled={disabled ? '' : undefined}
+ className={checkboxVariants({ size: 'sm' })}
+ >
+ {(customCheckedByStep[step] ?? false) && (
+
+ )}
+
+
+ ) : (
+
+ )}
+
setFreeText(e.target.value)}
+ onBlur={() => {
+ if (freeText.trim().length === 0) {
+ setFreeTextEditing(false)
+ if (isMulti) setCustomChecked(false)
+ }
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Escape') {
+ e.currentTarget.blur()
+ return
+ }
+ if (e.key === 'Enter' && canSubmitStep) {
+ e.preventDefault()
+ if (isMulti) {
+ submitMultiStep()
+ } else {
+ submitSingleFreeText()
+ }
+ }
+ }}
+ aria-label={question.prompt}
+ className='min-w-0 flex-1 border-0 bg-transparent p-0 text-[var(--text-body)] text-sm outline-none disabled:cursor-not-allowed'
+ />
+ {!isMulti && (
+
+
+
+ )}
+
+ ) : (
+
{
+ setFreeTextEditing(true)
+ if (isMulti) setCustomChecked(true)
+ }}
+ className={cn(
+ OPTION_ROW_CLASSES,
+ options.length > 0 && 'border-t',
+ disabled ? 'cursor-not-allowed' : 'hover-hover:bg-[var(--surface-5)]'
+ )}
+ >
+ {isMulti ? (
+
+ ) : (
+
+ )}
+ Something else
+ {!isMulti && }
+
+ )}
+ {isMulti && (
+
+
+
+ Submit
+
+
+
+ )}
+
+
+ )
+}
+
+/**
+ * A step's combined answer: selected option labels in option order, with the
+ * typed "Something else" entry appended last. single_select carries at most
+ * one selection, so this collapses to the chosen label or the typed text.
+ */
+function answerFor(question: QuestionItem, selected: string[], custom: string): string {
+ const ordered = question.options
+ .map((option) => option.label)
+ .filter((label) => selected.includes(label))
+ const parts = custom.trim() ? [...ordered, custom.trim()] : ordered
+ return parts.join(', ')
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts
index c45b9199c31..16dbf2783e2 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts
@@ -6,6 +6,10 @@ export type {
MothershipErrorTagData,
OptionsTagData,
ParsedSpecialContent,
+ QuestionItem,
+ QuestionOption,
+ QuestionTagData,
+ QuestionType,
RuntimeSpecialTagName,
UsageUpgradeAction,
UsageUpgradeTagData,
@@ -17,9 +21,12 @@ export {
PendingTagIndicator,
parseFileTag,
parseJsonTagBody,
+ parseLastQuestionTag,
+ parseQuestionTagBody,
parseSpecialTags,
parseTagAttributes,
parseTextTagBody,
+ QUESTION_TYPES,
SpecialTags,
USAGE_UPGRADE_ACTIONS,
WORKSPACE_RESOURCE_TAG_TYPES,
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts
new file mode 100644
index 00000000000..638e3c3a747
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts
@@ -0,0 +1,157 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import {
+ parseQuestionTagBody,
+ parseSpecialTags,
+} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags'
+
+const SINGLE_SELECT = {
+ type: 'single_select',
+ prompt: 'How should I handle the duplicate emails?',
+ options: [
+ { id: 'keep_newest', label: 'Keep the newest entry' },
+ { id: 'merge', label: 'Merge fields into one row' },
+ ],
+}
+
+const YES_NO = {
+ type: 'single_select',
+ prompt: 'Delete 4 archived workflows?',
+ options: [
+ { id: 'yes', label: 'Delete them' },
+ { id: 'no', label: 'Cancel' },
+ ],
+}
+
+const MULTI_SELECT = {
+ type: 'multi_select',
+ prompt: 'Which channels should the report go to?',
+ options: [
+ { id: 'slack', label: 'Slack' },
+ { id: 'email', label: 'Email' },
+ { id: 'sheet', label: 'Google Sheet' },
+ ],
+}
+
+describe('parseQuestionTagBody', () => {
+ it('normalizes a single object body to a one-element array', () => {
+ expect(parseQuestionTagBody(JSON.stringify(SINGLE_SELECT))).toEqual([SINGLE_SELECT])
+ })
+
+ it('preserves array order for multi-step bodies', () => {
+ const parsed = parseQuestionTagBody(JSON.stringify([SINGLE_SELECT, YES_NO, MULTI_SELECT]))
+ expect(parsed).toEqual([SINGLE_SELECT, YES_NO, MULTI_SELECT])
+ })
+
+ it('accepts multi_select questions', () => {
+ expect(parseQuestionTagBody(JSON.stringify(MULTI_SELECT))).toEqual([MULTI_SELECT])
+ })
+
+ it('rejects single_select without options', () => {
+ expect(parseQuestionTagBody(JSON.stringify({ type: 'single_select', prompt: 'Pick' }))).toBe(
+ null
+ )
+ })
+
+ it('rejects empty options', () => {
+ expect(
+ parseQuestionTagBody(JSON.stringify({ type: 'single_select', prompt: 'Sure?', options: [] }))
+ ).toBe(null)
+ })
+
+ it('rejects the removed text and confirm types', () => {
+ expect(parseQuestionTagBody(JSON.stringify({ type: 'text', prompt: 'What time zone?' }))).toBe(
+ null
+ )
+ expect(parseQuestionTagBody(JSON.stringify({ ...YES_NO, type: 'confirm' }))).toBe(null)
+ })
+
+ it('strips agent-supplied catch-all options (the card provides its own)', () => {
+ const withOther = {
+ ...SINGLE_SELECT,
+ options: [...SINGLE_SELECT.options, { id: 'other', label: 'Something else' }],
+ }
+ expect(parseQuestionTagBody(JSON.stringify(withOther))).toEqual([SINGLE_SELECT])
+ })
+
+ it('rejects a question whose every option is a catch-all', () => {
+ const onlyOther = {
+ type: 'single_select',
+ prompt: 'Pick one',
+ options: [
+ { id: 'a', label: 'Other' },
+ { id: 'b', label: 'None of the above' },
+ ],
+ }
+ expect(parseQuestionTagBody(JSON.stringify(onlyOther))).toBe(null)
+ })
+
+ it('rejects an empty prompt', () => {
+ expect(parseQuestionTagBody(JSON.stringify({ ...SINGLE_SELECT, prompt: ' ' }))).toBe(null)
+ })
+
+ it('rejects a malformed option', () => {
+ expect(
+ parseQuestionTagBody(JSON.stringify({ ...SINGLE_SELECT, options: [{ id: 'keep_newest' }] }))
+ ).toBe(null)
+ })
+
+ it('rejects an array containing one invalid question', () => {
+ expect(parseQuestionTagBody(JSON.stringify([SINGLE_SELECT, { type: 'single_select' }]))).toBe(
+ null
+ )
+ })
+
+ it('rejects empty arrays and non-JSON bodies', () => {
+ expect(parseQuestionTagBody('[]')).toBe(null)
+ expect(parseQuestionTagBody('not json')).toBe(null)
+ })
+})
+
+describe('parseSpecialTags with ', () => {
+ it('extracts a complete question tag interleaved with text', () => {
+ const content = `Before the tag. ${JSON.stringify(SINGLE_SELECT)} After the tag.`
+ const { segments, hasPendingTag } = parseSpecialTags(content, false)
+ expect(hasPendingTag).toBe(false)
+ expect(segments).toEqual([
+ { type: 'text', content: 'Before the tag. ' },
+ { type: 'question', data: [SINGLE_SELECT] },
+ { type: 'text', content: ' After the tag.' },
+ ])
+ })
+
+ it('extracts a multi-step array body as one segment', () => {
+ const content = `${JSON.stringify([SINGLE_SELECT, YES_NO, MULTI_SELECT])} `
+ const { segments } = parseSpecialTags(content, false)
+ expect(segments).toEqual([{ type: 'question', data: [SINGLE_SELECT, YES_NO, MULTI_SELECT] }])
+ })
+
+ it('flags an unclosed question tag as pending while streaming', () => {
+ const { segments, hasPendingTag } = parseSpecialTags(
+ 'Thinking about it. [{"type":"single_sel',
+ true
+ )
+ expect(hasPendingTag).toBe(true)
+ expect(segments).toEqual([{ type: 'text', content: 'Thinking about it. ' }])
+ })
+
+ it('strips a trailing partial opening tag while streaming', () => {
+ const { segments, hasPendingTag } = parseSpecialTags('Let me ask. {
+ const { segments, hasPendingTag } = parseSpecialTags(
+ 'Before. {"type":"single_select"} After.',
+ false
+ )
+ expect(hasPendingTag).toBe(false)
+ expect(segments).toEqual([
+ { type: 'text', content: 'Before. ' },
+ { type: 'text', content: ' After.' },
+ ])
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
index e8de183ad03..73109b106da 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
@@ -17,6 +17,7 @@ import { useParams } from 'next/navigation'
import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
+import { QuestionDisplay } from '@/app/workspace/[workspaceId]/home/components/message-content/components/question'
import type {
ChatMessageContext,
MothershipResource,
@@ -94,6 +95,30 @@ export interface FileTagData {
content: string
}
+export const QUESTION_TYPES = ['single_select', 'multi_select'] as const
+
+export type QuestionType = (typeof QUESTION_TYPES)[number]
+
+export interface QuestionOption {
+ id: string
+ label: string
+}
+
+/**
+ * One question in a `` tag: a single_select or multi_select with at
+ * least one real option. The card always appends its own free-text "Something
+ * else" row, so agent-supplied catch-all options ("Other", "Something else",
+ * ...) are stripped during parsing.
+ */
+export interface QuestionItem {
+ type: QuestionType
+ prompt: string
+ options: QuestionOption[]
+}
+
+/** Normalized `` payload: single-object bodies become a one-element array. */
+export type QuestionTagData = QuestionItem[]
+
export const WORKSPACE_RESOURCE_TAG_TYPES = ['workflow', 'table', 'file'] as const
export type WorkspaceResourceTagType = (typeof WORKSPACE_RESOURCE_TAG_TYPES)[number]
@@ -113,6 +138,7 @@ export type ContentSegment =
| { type: 'credential'; data: CredentialTagData }
| { type: 'mothership-error'; data: MothershipErrorTagData }
| { type: 'workspace_resource'; data: WorkspaceResourceTagData }
+ | { type: 'question'; data: QuestionTagData }
export type RuntimeSpecialTagName =
| 'thinking'
@@ -121,6 +147,7 @@ export type RuntimeSpecialTagName =
| 'mothership-error'
| 'file'
| 'workspace_resource'
+ | 'question'
export interface ParsedSpecialContent {
segments: ContentSegment[]
@@ -134,6 +161,7 @@ const RUNTIME_SPECIAL_TAG_NAMES = [
'mothership-error',
'file',
'workspace_resource',
+ 'question',
] as const
const SPECIAL_TAG_NAMES = [
@@ -143,6 +171,7 @@ const SPECIAL_TAG_NAMES = [
'credential',
'mothership-error',
'workspace_resource',
+ 'question',
] as const
function isRecord(value: unknown): value is Record {
@@ -220,6 +249,83 @@ function isWorkspaceResourceTagData(value: unknown): value is WorkspaceResourceT
return id.length > 0
}
+function isQuestionOption(value: unknown): value is QuestionOption {
+ if (!isRecord(value)) return false
+ return typeof value.id === 'string' && typeof value.label === 'string'
+}
+
+/**
+ * Catch-all labels the agent must not supply as options — the card renders
+ * its own free-text "Something else" row. Matching options are stripped; a
+ * question left with no real options is invalid.
+ */
+const SELF_PROVIDED_OPTION_LABELS = new Set([
+ 'other',
+ 'others',
+ 'something else',
+ 'none of the above',
+ 'none of these',
+])
+
+function isQuestionItem(value: unknown): value is QuestionItem {
+ if (!isRecord(value)) return false
+ if (
+ typeof value.type !== 'string' ||
+ !(QUESTION_TYPES as readonly string[]).includes(value.type)
+ ) {
+ return false
+ }
+ if (typeof value.prompt !== 'string' || value.prompt.trim().length === 0) return false
+ return (
+ Array.isArray(value.options) &&
+ value.options.length > 0 &&
+ value.options.every(isQuestionOption)
+ )
+}
+
+/** Strips agent-supplied catch-all options; null when none remain. */
+function sanitizeQuestionItem(item: QuestionItem): QuestionItem | null {
+ const options = item.options.filter(
+ (option) => !SELF_PROVIDED_OPTION_LABELS.has(option.label.trim().toLowerCase())
+ )
+ if (options.length === 0) return null
+ return options.length === item.options.length ? item : { ...item, options }
+}
+
+/**
+ * Parses a `` tag body. Accepts a single question object or a
+ * non-empty array of them; single objects are normalized to a one-element
+ * array so the renderer only handles the array shape.
+ */
+/**
+ * Extracts the last complete `` tag payload from raw message
+ * content. Used by the chat list to pair an assistant question card with the
+ * user message that answered it.
+ */
+export function parseLastQuestionTag(content: string): QuestionTagData | null {
+ const matches = content.match(/([\s\S]*?)<\/question>/g)
+ if (!matches || matches.length === 0) return null
+ const last = matches[matches.length - 1]
+ return parseQuestionTagBody(last.slice(''.length, -' '.length))
+}
+
+export function parseQuestionTagBody(body: string): QuestionTagData | null {
+ try {
+ const parsed = JSON.parse(body) as unknown
+ const items = Array.isArray(parsed) ? parsed : [parsed]
+ if (items.length === 0 || !items.every(isQuestionItem)) return null
+ const sanitized: QuestionItem[] = []
+ for (const item of items) {
+ const clean = sanitizeQuestionItem(item)
+ if (!clean) return null
+ sanitized.push(clean)
+ }
+ return sanitized
+ } catch {
+ return null
+ }
+}
+
export function parseJsonTagBody(
body: string,
isExpectedShape: (value: unknown) => value is T
@@ -268,6 +374,7 @@ function parseSpecialTagData(
| { type: 'credential'; data: CredentialTagData }
| { type: 'mothership-error'; data: MothershipErrorTagData }
| { type: 'workspace_resource'; data: WorkspaceResourceTagData }
+ | { type: 'question'; data: QuestionTagData }
| null {
if (tagName === 'thinking') {
const content = parseTextTagBody(body)
@@ -299,6 +406,11 @@ function parseSpecialTagData(
return data ? { type: 'workspace_resource', data } : null
}
+ if (tagName === 'question') {
+ const data = parseQuestionTagBody(body)
+ return data ? { type: 'question', data } : null
+ }
+
return null
}
@@ -398,6 +510,8 @@ const THINKING_BLOCKS = [
interface SpecialTagsProps {
segment: Exclude
+ /** Transcript-derived answers for this message's question card (renders the recap). */
+ questionAnswers?: string[]
onOptionSelect?: (id: string) => void
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
}
@@ -408,6 +522,7 @@ interface SpecialTagsProps {
*/
export function SpecialTags({
segment,
+ questionAnswers,
onOptionSelect,
onWorkspaceResourceSelect,
}: SpecialTagsProps) {
@@ -424,6 +539,10 @@ export function SpecialTags({
return
case 'workspace_resource':
return
+ case 'question':
+ return (
+
+ )
default:
return null
}
@@ -760,7 +879,7 @@ function CredentialDisplay({ data }: { data: CredentialTagData }) {
href={data.value}
target='_blank'
rel='noopener noreferrer'
- className='flex items-center gap-2 rounded-lg border border-[var(--divider)] px-3 py-2.5 transition-colors hover-hover:bg-[var(--surface-5)]'
+ className='flex items-center gap-2 rounded-2xl border border-[var(--border-1)] px-3 py-2.5 transition-colors hover-hover:bg-[var(--surface-5)]'
>
{createElement(Icon, { className: 'size-[16px] shrink-0' })}
Connect {data.provider}
@@ -788,7 +907,7 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) {
const buttonLabel = data.action === 'upgrade_plan' ? 'Upgrade Plan' : 'Increase Limit'
return (
-
+
void
onPhaseChange?: (phase: MessagePhase) => void
}
@@ -659,6 +661,7 @@ function MessageContentInner({
blocks,
fallbackContent,
isStreaming = false,
+ questionAnswers,
onOptionSelect,
onPhaseChange,
}: MessageContentProps) {
@@ -724,6 +727,7 @@ function MessageContentInner({
segmentIndex: i,
segmentCount: segments.length,
})}
+ questionAnswers={questionAnswers}
onOptionSelect={onOptionSelect}
onWorkspaceResourceSelect={onWorkspaceResourceSelect}
onRevealStateChange={
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts
index 6a06125c07a..350eae9d6f4 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts
@@ -38,6 +38,7 @@ const TOOL_ICONS: Record = {
manage_skill: Asterisk,
user_memory: Database,
function_execute: TerminalWindow,
+ run_code: TerminalWindow,
superagent: Blimp,
user_table: TableIcon,
workspace_file: File,
@@ -51,12 +52,16 @@ const TOOL_ICONS: Record = {
auth: Integration,
knowledge: Database,
knowledge_base: Database,
+ search_knowledge_base: Database,
table: TableIcon,
+ query_user_table: TableIcon,
scheduled_task: Calendar,
job: Calendar,
agent: AgentIcon,
custom_tool: Wrench,
research: Search,
+ scout: Search,
+ search: Search,
context_compaction: Asterisk,
open_resource: Eye,
file: File,
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
index 841538b2e17..c85ef9aa7db 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
@@ -20,7 +20,11 @@ import {
MessageContent,
type MessagePhase,
} from '@/app/workspace/[workspaceId]/home/components/message-content'
-import { PendingTagIndicator } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
+import { parseQuestionAnswerMessage } from '@/app/workspace/[workspaceId]/home/components/message-content/components/question'
+import {
+ PendingTagIndicator,
+ parseLastQuestionTag,
+} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
import { QueuedMessages } from '@/app/workspace/[workspaceId]/home/components/queued-messages'
import {
UserInput,
@@ -163,6 +167,8 @@ interface AssistantMessageRowProps {
message: ChatMessage
isStreaming: boolean
precedingUserContent?: string
+ /** Transcript-derived answers for this message's question card (renders the recap). */
+ questionAnswers?: string[]
rowClassName: string
onOptionSelect?: (id: string) => void
onAnimatingChange?: (animating: boolean) => void
@@ -172,6 +178,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
message,
isStreaming,
precedingUserContent,
+ questionAnswers,
rowClassName,
onOptionSelect,
onAnimatingChange,
@@ -197,7 +204,11 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
return null
}
- const showActions = phase === 'settled' && (message.content || hasAnyBlocks)
+ // A message that ends with a question card is an input surface, not a
+ // reactable assistant turn: no copy/thumbs row beneath the card, whether
+ // the card is awaiting answers or collapsed to its recap.
+ const endsWithQuestion = trimmedContent.endsWith('')
+ const showActions = phase === 'settled' && !endsWithQuestion && (message.content || hasAnyBlocks)
return (
@@ -205,6 +216,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
blocks={blocks}
fallbackContent={message.content}
isStreaming={isStreaming}
+ questionAnswers={questionAnswers}
onOptionSelect={onOptionSelect}
onPhaseChange={setPhase}
/>
@@ -303,6 +315,29 @@ export function MothershipChat({
return out
}, [messages])
+ /**
+ * Pairs each assistant question card with the user message that answered it
+ * (strict `Prompt — Answer` match). The paired user message is hidden — the
+ * answered card IS the user turn — and the assistant row renders the card
+ * as a recap with these answers, both live and after reload.
+ */
+ const questionPairing = useMemo(() => {
+ const answersByIndex: Array
= []
+ const hiddenUserByIndex: Array = []
+ for (const [index, message] of messages.entries()) {
+ if (message.role !== 'assistant' || !message.content?.includes('')) continue
+ const next = messages[index + 1]
+ if (!next || next.role !== 'user' || !next.content) continue
+ const questions = parseLastQuestionTag(message.content)
+ if (!questions) continue
+ const answers = parseQuestionAnswerMessage(questions, next.content)
+ if (!answers) continue
+ answersByIndex[index] = answers
+ hiddenUserByIndex[index + 1] = true
+ }
+ return { answersByIndex, hiddenUserByIndex }
+ }, [messages])
+
/**
* Always keep the last row in the rendered window. It is the live/streaming
* row; unmounting it (by scrolling far enough up that it leaves the overscan
@@ -424,19 +459,22 @@ export function MothershipChat({
style={{ transform: `translateY(${virtualItem.start}px)` }}
>
{msg.role === 'user' ? (
-
+ questionPairing.hiddenUserByIndex[index] ? null : (
+
+ )
) : (
= {
knowledge: 'Knowledge Agent',
table: 'Table Agent',
custom_tool: 'Custom Tool Agent',
+ scout: 'Scout Agent',
+ search: 'Search Agent',
superagent: 'Superagent',
run: 'Run Agent',
agent: 'Tools Agent',
diff --git a/apps/sim/lib/copilot/generated/metrics-v1.ts b/apps/sim/lib/copilot/generated/metrics-v1.ts
index fefc4534ea3..f99d761574f 100644
--- a/apps/sim/lib/copilot/generated/metrics-v1.ts
+++ b/apps/sim/lib/copilot/generated/metrics-v1.ts
@@ -28,6 +28,8 @@ export const Metric = {
CopilotVfsMaterializeDuration: 'copilot.vfs.materialize.duration',
GenAiClientCacheTokenUsage: 'gen_ai.client.cache.token.usage',
GenAiClientTokenUsage: 'gen_ai.client.token.usage',
+ LlmClientCompactions: 'llm.client.compactions',
+ LlmClientContextTokens: 'llm.client.context_tokens',
LlmClientErrors: 'llm.client.errors',
LlmClientOutputCutoff: 'llm.client.output_cutoff',
LlmClientStreamDuration: 'llm.client.stream.duration',
@@ -53,6 +55,8 @@ export const MetricValues: readonly MetricValue[] = [
'copilot.vfs.materialize.duration',
'gen_ai.client.cache.token.usage',
'gen_ai.client.token.usage',
+ 'llm.client.compactions',
+ 'llm.client.context_tokens',
'llm.client.errors',
'llm.client.output_cutoff',
'llm.client.stream.duration',
diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts
index 4a4c24a1594..ac259253df3 100644
--- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts
+++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts
@@ -71,22 +71,25 @@ export interface ToolCatalogEntry {
| 'open_resource'
| 'promote_to_live'
| 'query_logs'
+ | 'query_user_table'
| 'read'
| 'redeploy'
| 'rename_file'
| 'rename_file_folder'
| 'rename_workflow'
- | 'research'
| 'respond'
| 'restore_resource'
| 'run'
| 'run_block'
+ | 'run_code'
| 'run_from_block'
| 'run_workflow'
| 'run_workflow_until_block'
| 'scheduled_task'
| 'scrape_page'
+ | 'search'
| 'search_documentation'
+ | 'search_knowledge_base'
| 'search_library_docs'
| 'search_online'
| 'search_patterns'
@@ -98,7 +101,6 @@ export interface ToolCatalogEntry {
| 'update_deployment_version'
| 'update_scheduled_task_history'
| 'update_workspace_mcp_server'
- | 'user_memory'
| 'user_table'
| 'workflow'
| 'workspace_file'
@@ -169,22 +171,25 @@ export interface ToolCatalogEntry {
| 'open_resource'
| 'promote_to_live'
| 'query_logs'
+ | 'query_user_table'
| 'read'
| 'redeploy'
| 'rename_file'
| 'rename_file_folder'
| 'rename_workflow'
- | 'research'
| 'respond'
| 'restore_resource'
| 'run'
| 'run_block'
+ | 'run_code'
| 'run_from_block'
| 'run_workflow'
| 'run_workflow_until_block'
| 'scheduled_task'
| 'scrape_page'
+ | 'search'
| 'search_documentation'
+ | 'search_knowledge_base'
| 'search_library_docs'
| 'search_online'
| 'search_patterns'
@@ -196,7 +201,6 @@ export interface ToolCatalogEntry {
| 'update_deployment_version'
| 'update_scheduled_task_history'
| 'update_workspace_mcp_server'
- | 'user_memory'
| 'user_table'
| 'workflow'
| 'workspace_file'
@@ -211,9 +215,9 @@ export interface ToolCatalogEntry {
| 'file'
| 'knowledge'
| 'media'
- | 'research'
| 'run'
| 'scheduled_task'
+ | 'search'
| 'superagent'
| 'table'
| 'workflow'
@@ -3015,6 +3019,55 @@ export const QueryLogs: ToolCatalogEntry = {
},
}
+export const QueryUserTable: ToolCatalogEntry = {
+ id: 'query_user_table',
+ name: 'query_user_table',
+ route: 'sim',
+ mode: 'async',
+ parameters: {
+ type: 'object',
+ properties: {
+ args: {
+ type: 'object',
+ description: 'Arguments for the operation',
+ properties: {
+ filter: { type: 'object', description: 'MongoDB-style filter for query_rows' },
+ limit: {
+ type: 'number',
+ description: 'Maximum rows to return (optional, default 100, max 1000 per call)',
+ },
+ offset: {
+ type: 'number',
+ description: 'Number of rows to skip (optional for query_rows, default 0)',
+ },
+ rowId: { type: 'string', description: 'Row ID (required for get_row)' },
+ sort: {
+ type: 'object',
+ description:
+ "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)",
+ },
+ tableId: { type: 'string', description: 'Table ID (required for all operations)' },
+ },
+ },
+ operation: {
+ type: 'string',
+ description: 'The read operation to perform',
+ enum: ['get', 'get_schema', 'get_row', 'query_rows'],
+ },
+ },
+ 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 Read: ToolCatalogEntry = {
id: 'read',
name: 'read',
@@ -3182,20 +3235,6 @@ export const RenameWorkflow: ToolCatalogEntry = {
requiredPermission: 'write',
}
-export const Research: ToolCatalogEntry = {
- id: 'research',
- name: 'research',
- route: 'subagent',
- mode: 'async',
- parameters: {
- properties: { topic: { description: 'The topic to research.', type: 'string' } },
- required: ['topic'],
- type: 'object',
- },
- subagentId: 'research',
- internal: true,
-}
-
export const Respond: ToolCatalogEntry = {
id: 'respond',
name: 'respond',
@@ -3293,6 +3332,99 @@ export const RunBlock: ToolCatalogEntry = {
clientExecutable: true,
}
+export const RunCode: ToolCatalogEntry = {
+ id: 'run_code',
+ name: 'run_code',
+ 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 access to pre-installed CLI tools and workspace env vars as $VAR_NAME.',
+ },
+ 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'],
+ },
+ title: {
+ type: 'string',
+ description:
+ 'Short user-visible label for this execution, e.g. "Sum June invoices" or "Verify email formats".',
+ },
+ },
+ required: ['code'],
+ },
+ requiredPermission: 'write',
+ capabilities: ['file_input', 'directory_input', 'table_input'],
+}
+
export const RunFromBlock: ToolCatalogEntry = {
id: 'run_from_block',
name: 'run_from_block',
@@ -3456,6 +3588,26 @@ export const ScrapePage: ToolCatalogEntry = {
},
}
+export const Search: ToolCatalogEntry = {
+ id: 'search',
+ name: 'search',
+ route: 'subagent',
+ mode: 'async',
+ parameters: {
+ properties: {
+ task: {
+ description:
+ "One short scoping sentence — the search agent has full conversation context. Example: 'find current Stripe metered-billing API limits' or 'count how many rows in the leads table have invalid emails'.",
+ type: 'string',
+ },
+ },
+ required: ['task'],
+ type: 'object',
+ },
+ subagentId: 'search',
+ internal: true,
+}
+
export const SearchDocumentation: ToolCatalogEntry = {
id: 'search_documentation',
name: 'search_documentation',
@@ -3471,6 +3623,49 @@ export const SearchDocumentation: ToolCatalogEntry = {
},
}
+export const SearchKnowledgeBase: ToolCatalogEntry = {
+ id: 'search_knowledge_base',
+ name: 'search_knowledge_base',
+ route: 'sim',
+ mode: 'async',
+ parameters: {
+ type: 'object',
+ properties: {
+ args: {
+ type: 'object',
+ description: 'Arguments for the operation',
+ properties: {
+ knowledgeBaseId: {
+ type: 'string',
+ description: 'Knowledge base ID (required for all operations)',
+ },
+ query: { type: 'string', description: "Search query text (required for 'query')" },
+ topK: {
+ type: 'number',
+ description: 'Number of results to return (1-50, default: 5)',
+ default: 5,
+ },
+ },
+ },
+ operation: {
+ type: 'string',
+ description: 'The read operation to perform',
+ enum: ['get', 'query', 'list_tags'],
+ },
+ },
+ 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 SearchLibraryDocs: ToolCatalogEntry = {
id: 'search_library_docs',
name: 'search_library_docs',
@@ -3755,50 +3950,6 @@ export const UpdateWorkspaceMcpServer: ToolCatalogEntry = {
requiredPermission: 'admin',
}
-export const UserMemory: ToolCatalogEntry = {
- id: 'user_memory',
- name: 'user_memory',
- route: 'go',
- mode: 'sync',
- parameters: {
- type: 'object',
- properties: {
- confidence: {
- type: 'number',
- description: 'Confidence level 0-1 (default 1.0 for explicit, 0.8 for inferred)',
- },
- correct_value: {
- type: 'string',
- description:
- "The correct value to replace the wrong one (for 'correct' operation). Requires `key` (the memory to replace).",
- },
- key: {
- type: 'string',
- description: "Unique key for the memory (e.g., 'preferred_model', 'slack_credential')",
- },
- limit: { type: 'number', description: 'Number of results for search (default 10)' },
- memory_type: {
- type: 'string',
- description: "Type of memory: 'preference', 'entity', 'history', or 'correction'",
- enum: ['preference', 'entity', 'history', 'correction'],
- },
- operation: {
- type: 'string',
- description: "Operation: 'add', 'search', 'delete', 'correct', or 'list'",
- enum: ['add', 'search', 'delete', 'correct', 'list'],
- },
- query: { type: 'string', description: 'Search query to find relevant memories' },
- source: {
- type: 'string',
- description: "Source: 'explicit' (user told you) or 'inferred' (you observed)",
- enum: ['explicit', 'inferred'],
- },
- value: { type: 'string', description: 'Value to remember' },
- },
- required: ['operation'],
- },
-}
-
export const UserTable: ToolCatalogEntry = {
id: 'user_table',
name: 'user_table',
@@ -4461,22 +4612,36 @@ export const MaterializeFileOperationValues = [
MaterializeFileOperation.import,
] as const
-export const UserMemoryOperation = {
- add: 'add',
- search: 'search',
- delete: 'delete',
- correct: 'correct',
- list: 'list',
+export const QueryUserTableOperation = {
+ get: 'get',
+ getSchema: 'get_schema',
+ getRow: 'get_row',
+ queryRows: 'query_rows',
+} as const
+
+export type QueryUserTableOperation =
+ (typeof QueryUserTableOperation)[keyof typeof QueryUserTableOperation]
+
+export const QueryUserTableOperationValues = [
+ QueryUserTableOperation.get,
+ QueryUserTableOperation.getSchema,
+ QueryUserTableOperation.getRow,
+ QueryUserTableOperation.queryRows,
+] as const
+
+export const SearchKnowledgeBaseOperation = {
+ get: 'get',
+ query: 'query',
+ listTags: 'list_tags',
} as const
-export type UserMemoryOperation = (typeof UserMemoryOperation)[keyof typeof UserMemoryOperation]
+export type SearchKnowledgeBaseOperation =
+ (typeof SearchKnowledgeBaseOperation)[keyof typeof SearchKnowledgeBaseOperation]
-export const UserMemoryOperationValues = [
- UserMemoryOperation.add,
- UserMemoryOperation.search,
- UserMemoryOperation.delete,
- UserMemoryOperation.correct,
- UserMemoryOperation.list,
+export const SearchKnowledgeBaseOperationValues = [
+ SearchKnowledgeBaseOperation.get,
+ SearchKnowledgeBaseOperation.query,
+ SearchKnowledgeBaseOperation.listTags,
] as const
export const UserTableOperation = {
@@ -4629,22 +4794,25 @@ export const TOOL_CATALOG: Record = {
[OpenResource.id]: OpenResource,
[PromoteToLive.id]: PromoteToLive,
[QueryLogs.id]: QueryLogs,
+ [QueryUserTable.id]: QueryUserTable,
[Read.id]: Read,
[Redeploy.id]: Redeploy,
[RenameFile.id]: RenameFile,
[RenameFileFolder.id]: RenameFileFolder,
[RenameWorkflow.id]: RenameWorkflow,
- [Research.id]: Research,
[Respond.id]: Respond,
[RestoreResource.id]: RestoreResource,
[Run.id]: Run,
[RunBlock.id]: RunBlock,
+ [RunCode.id]: RunCode,
[RunFromBlock.id]: RunFromBlock,
[RunWorkflow.id]: RunWorkflow,
[RunWorkflowUntilBlock.id]: RunWorkflowUntilBlock,
[ScheduledTask.id]: ScheduledTask,
[ScrapePage.id]: ScrapePage,
+ [Search.id]: Search,
[SearchDocumentation.id]: SearchDocumentation,
+ [SearchKnowledgeBase.id]: SearchKnowledgeBase,
[SearchLibraryDocs.id]: SearchLibraryDocs,
[SearchOnline.id]: SearchOnline,
[SearchPatterns.id]: SearchPatterns,
@@ -4656,7 +4824,6 @@ export const TOOL_CATALOG: Record = {
[UpdateDeploymentVersion.id]: UpdateDeploymentVersion,
[UpdateScheduledTaskHistory.id]: UpdateScheduledTaskHistory,
[UpdateWorkspaceMcpServer.id]: UpdateWorkspaceMcpServer,
- [UserMemory.id]: UserMemory,
[UserTable.id]: UserTable,
[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 dcaea0db6ea..1a4948e59fc 100644
--- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts
+++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts
@@ -10,7 +10,7 @@ export interface ToolRuntimeSchemaEntry {
}
export const TOOL_RUNTIME_SCHEMAS: Record = {
- agent: {
+ ['agent']: {
parameters: {
properties: {
request: {
@@ -23,7 +23,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- auth: {
+ ['auth']: {
parameters: {
properties: {
request: {
@@ -36,7 +36,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- check_deployment_status: {
+ ['check_deployment_status']: {
parameters: {
type: 'object',
properties: {
@@ -48,7 +48,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- complete_scheduled_task: {
+ ['complete_scheduled_task']: {
parameters: {
type: 'object',
properties: {
@@ -61,7 +61,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- crawl_website: {
+ ['crawl_website']: {
parameters: {
type: 'object',
properties: {
@@ -96,7 +96,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- create_file: {
+ ['create_file']: {
parameters: {
type: 'object',
properties: {
@@ -162,7 +162,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
required: ['success', 'message'],
},
},
- create_file_folder: {
+ ['create_file_folder']: {
parameters: {
type: 'object',
properties: {
@@ -180,7 +180,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- create_workflow: {
+ ['create_workflow']: {
parameters: {
type: 'object',
properties: {
@@ -205,7 +205,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- create_workspace_mcp_server: {
+ ['create_workspace_mcp_server']: {
parameters: {
type: 'object',
properties: {
@@ -238,7 +238,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- delete_file: {
+ ['delete_file']: {
parameters: {
type: 'object',
properties: {
@@ -268,7 +268,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
required: ['success', 'message'],
},
},
- delete_file_folder: {
+ ['delete_file_folder']: {
parameters: {
type: 'object',
properties: {
@@ -284,7 +284,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- delete_workflow: {
+ ['delete_workflow']: {
parameters: {
type: 'object',
properties: {
@@ -300,7 +300,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- delete_workspace_mcp_server: {
+ ['delete_workspace_mcp_server']: {
parameters: {
type: 'object',
properties: {
@@ -313,7 +313,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- deploy: {
+ ['deploy']: {
parameters: {
properties: {
request: {
@@ -327,7 +327,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- deploy_api: {
+ ['deploy_api']: {
parameters: {
type: 'object',
properties: {
@@ -411,7 +411,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
],
},
},
- deploy_chat: {
+ ['deploy_chat']: {
parameters: {
type: 'object',
properties: {
@@ -570,7 +570,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
],
},
},
- deploy_mcp: {
+ ['deploy_mcp']: {
parameters: {
type: 'object',
properties: {
@@ -686,7 +686,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
required: ['deploymentType', 'deploymentStatus'],
},
},
- diff_workflows: {
+ ['diff_workflows']: {
parameters: {
type: 'object',
properties: {
@@ -710,7 +710,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- download_to_workspace_file: {
+ ['download_to_workspace_file']: {
parameters: {
type: 'object',
properties: {
@@ -759,7 +759,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- edit_content: {
+ ['edit_content']: {
parameters: {
type: 'object',
properties: {
@@ -791,7 +791,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
required: ['success', 'message'],
},
},
- edit_workflow: {
+ ['edit_workflow']: {
parameters: {
type: 'object',
properties: {
@@ -830,7 +830,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- enrichment_run: {
+ ['enrichment_run']: {
parameters: {
type: 'object',
properties: {
@@ -874,7 +874,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
required: ['matched', 'result'],
},
},
- ffmpeg: {
+ ['ffmpeg']: {
parameters: {
type: 'object',
properties: {
@@ -1055,7 +1055,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- file: {
+ ['file']: {
parameters: {
properties: {
prompt: {
@@ -1068,7 +1068,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- function_execute: {
+ ['function_execute']: {
parameters: {
type: 'object',
properties: {
@@ -1206,7 +1206,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- generate_api_key: {
+ ['generate_api_key']: {
parameters: {
type: 'object',
properties: {
@@ -1224,7 +1224,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- generate_audio: {
+ ['generate_audio']: {
parameters: {
type: 'object',
properties: {
@@ -1376,7 +1376,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- generate_image: {
+ ['generate_image']: {
parameters: {
type: 'object',
properties: {
@@ -1504,7 +1504,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- generate_video: {
+ ['generate_video']: {
parameters: {
type: 'object',
properties: {
@@ -1671,7 +1671,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- get_block_outputs: {
+ ['get_block_outputs']: {
parameters: {
type: 'object',
properties: {
@@ -1692,7 +1692,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- get_block_upstream_references: {
+ ['get_block_upstream_references']: {
parameters: {
type: 'object',
properties: {
@@ -1714,7 +1714,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- get_deployed_workflow_state: {
+ ['get_deployed_workflow_state']: {
parameters: {
type: 'object',
properties: {
@@ -1727,7 +1727,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- get_deployment_log: {
+ ['get_deployment_log']: {
parameters: {
type: 'object',
properties: {
@@ -1740,7 +1740,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- get_page_contents: {
+ ['get_page_contents']: {
parameters: {
type: 'object',
properties: {
@@ -1768,14 +1768,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- get_platform_actions: {
+ ['get_platform_actions']: {
parameters: {
type: 'object',
properties: {},
},
resultSchema: undefined,
},
- get_scheduled_task_logs: {
+ ['get_scheduled_task_logs']: {
parameters: {
type: 'object',
properties: {
@@ -1800,7 +1800,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- get_workflow_data: {
+ ['get_workflow_data']: {
parameters: {
type: 'object',
properties: {
@@ -1819,7 +1819,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- get_workflow_run_options: {
+ ['get_workflow_run_options']: {
parameters: {
type: 'object',
properties: {
@@ -1832,7 +1832,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- glob: {
+ ['glob']: {
parameters: {
type: 'object',
properties: {
@@ -1851,7 +1851,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- grep: {
+ ['grep']: {
parameters: {
type: 'object',
properties: {
@@ -1899,7 +1899,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- knowledge: {
+ ['knowledge']: {
parameters: {
properties: {
request: {
@@ -1912,7 +1912,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- knowledge_base: {
+ ['knowledge_base']: {
parameters: {
type: 'object',
properties: {
@@ -2105,7 +2105,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
required: ['success', 'message'],
},
},
- list_file_folders: {
+ ['list_file_folders']: {
parameters: {
type: 'object',
properties: {
@@ -2117,7 +2117,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- list_integration_tools: {
+ ['list_integration_tools']: {
parameters: {
properties: {
integration: {
@@ -2131,14 +2131,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- list_user_workspaces: {
+ ['list_user_workspaces']: {
parameters: {
type: 'object',
properties: {},
},
resultSchema: undefined,
},
- list_workspace_mcp_servers: {
+ ['list_workspace_mcp_servers']: {
parameters: {
type: 'object',
properties: {
@@ -2151,7 +2151,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- load_deployment: {
+ ['load_deployment']: {
parameters: {
type: 'object',
properties: {
@@ -2170,7 +2170,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- load_integration_tool: {
+ ['load_integration_tool']: {
parameters: {
properties: {
tool_ids: {
@@ -2187,7 +2187,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- manage_credential: {
+ ['manage_credential']: {
parameters: {
type: 'object',
properties: {
@@ -2216,7 +2216,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- manage_custom_tool: {
+ ['manage_custom_tool']: {
parameters: {
type: 'object',
properties: {
@@ -2296,7 +2296,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- manage_folder: {
+ ['manage_folder']: {
parameters: {
type: 'object',
properties: {
@@ -2335,7 +2335,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- manage_mcp_tool: {
+ ['manage_mcp_tool']: {
parameters: {
type: 'object',
properties: {
@@ -2387,7 +2387,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- manage_scheduled_task: {
+ ['manage_scheduled_task']: {
parameters: {
type: 'object',
properties: {
@@ -2462,7 +2462,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- manage_skill: {
+ ['manage_skill']: {
parameters: {
type: 'object',
properties: {
@@ -2495,7 +2495,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- materialize_file: {
+ ['materialize_file']: {
parameters: {
type: 'object',
properties: {
@@ -2519,7 +2519,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- media: {
+ ['media']: {
parameters: {
properties: {
prompt: {
@@ -2532,7 +2532,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- move_file: {
+ ['move_file']: {
parameters: {
type: 'object',
properties: {
@@ -2553,7 +2553,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- move_file_folder: {
+ ['move_file_folder']: {
parameters: {
type: 'object',
properties: {
@@ -2571,7 +2571,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- move_workflow: {
+ ['move_workflow']: {
parameters: {
type: 'object',
properties: {
@@ -2591,7 +2591,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- oauth_get_auth_link: {
+ ['oauth_get_auth_link']: {
parameters: {
type: 'object',
properties: {
@@ -2605,7 +2605,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- oauth_request_access: {
+ ['oauth_request_access']: {
parameters: {
type: 'object',
properties: {
@@ -2619,7 +2619,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- open_resource: {
+ ['open_resource']: {
parameters: {
type: 'object',
properties: {
@@ -2653,7 +2653,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- promote_to_live: {
+ ['promote_to_live']: {
parameters: {
type: 'object',
properties: {
@@ -2672,7 +2672,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- query_logs: {
+ ['query_logs']: {
parameters: {
type: 'object',
properties: {
@@ -2783,7 +2783,69 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- read: {
+ ['query_user_table']: {
+ parameters: {
+ type: 'object',
+ properties: {
+ args: {
+ type: 'object',
+ description: 'Arguments for the operation',
+ properties: {
+ filter: {
+ type: 'object',
+ description: 'MongoDB-style filter for query_rows',
+ },
+ limit: {
+ type: 'number',
+ description: 'Maximum rows to return (optional, default 100, max 1000 per call)',
+ },
+ offset: {
+ type: 'number',
+ description: 'Number of rows to skip (optional for query_rows, default 0)',
+ },
+ rowId: {
+ type: 'string',
+ description: 'Row ID (required for get_row)',
+ },
+ sort: {
+ type: 'object',
+ description:
+ "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)",
+ },
+ tableId: {
+ type: 'string',
+ description: 'Table ID (required for all operations)',
+ },
+ },
+ },
+ operation: {
+ type: 'string',
+ description: 'The read operation to perform',
+ enum: ['get', 'get_schema', 'get_row', 'query_rows'],
+ },
+ },
+ 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'],
+ },
+ },
+ ['read']: {
parameters: {
type: 'object',
properties: {
@@ -2810,7 +2872,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- redeploy: {
+ ['redeploy']: {
parameters: {
type: 'object',
properties: {
@@ -2889,7 +2951,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
],
},
},
- rename_file: {
+ ['rename_file']: {
parameters: {
type: 'object',
properties: {
@@ -2925,7 +2987,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
required: ['success', 'message'],
},
},
- rename_file_folder: {
+ ['rename_file_folder']: {
parameters: {
type: 'object',
properties: {
@@ -2942,7 +3004,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- rename_workflow: {
+ ['rename_workflow']: {
parameters: {
type: 'object',
properties: {
@@ -2959,20 +3021,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- research: {
- parameters: {
- properties: {
- topic: {
- description: 'The topic to research.',
- type: 'string',
- },
- },
- required: ['topic'],
- type: 'object',
- },
- resultSchema: undefined,
- },
- respond: {
+ ['respond']: {
parameters: {
additionalProperties: true,
properties: {
@@ -2995,7 +3044,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- restore_resource: {
+ ['restore_resource']: {
parameters: {
type: 'object',
properties: {
@@ -3013,7 +3062,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- run: {
+ ['run']: {
parameters: {
properties: {
context: {
@@ -3030,7 +3079,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- run_block: {
+ ['run_block']: {
parameters: {
type: 'object',
properties: {
@@ -3062,7 +3111,100 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- run_from_block: {
+ ['run_code']: {
+ 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 access to pre-installed CLI tools and workspace env vars as $VAR_NAME.',
+ },
+ 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'],
+ },
+ title: {
+ type: 'string',
+ description:
+ 'Short user-visible label for this execution, e.g. "Sum June invoices" or "Verify email formats".',
+ },
+ },
+ required: ['code'],
+ },
+ resultSchema: undefined,
+ },
+ ['run_from_block']: {
parameters: {
type: 'object',
properties: {
@@ -3094,7 +3236,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- run_workflow: {
+ ['run_workflow']: {
parameters: {
type: 'object',
properties: {
@@ -3132,7 +3274,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- run_workflow_until_block: {
+ ['run_workflow_until_block']: {
parameters: {
type: 'object',
properties: {
@@ -3175,7 +3317,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- scheduled_task: {
+ ['scheduled_task']: {
parameters: {
properties: {
request: {
@@ -3188,7 +3330,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- scrape_page: {
+ ['scrape_page']: {
parameters: {
type: 'object',
properties: {
@@ -3209,7 +3351,21 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- search_documentation: {
+ ['search']: {
+ parameters: {
+ properties: {
+ task: {
+ description:
+ "One short scoping sentence — the search agent has full conversation context. Example: 'find current Stripe metered-billing API limits' or 'count how many rows in the leads table have invalid emails'.",
+ type: 'string',
+ },
+ },
+ required: ['task'],
+ type: 'object',
+ },
+ resultSchema: undefined,
+ },
+ ['search_documentation']: {
parameters: {
type: 'object',
properties: {
@@ -3226,7 +3382,57 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- search_library_docs: {
+ ['search_knowledge_base']: {
+ parameters: {
+ type: 'object',
+ properties: {
+ args: {
+ type: 'object',
+ description: 'Arguments for the operation',
+ properties: {
+ knowledgeBaseId: {
+ type: 'string',
+ description: 'Knowledge base ID (required for all operations)',
+ },
+ query: {
+ type: 'string',
+ description: "Search query text (required for 'query')",
+ },
+ topK: {
+ type: 'number',
+ description: 'Number of results to return (1-50, default: 5)',
+ default: 5,
+ },
+ },
+ },
+ operation: {
+ type: 'string',
+ description: 'The read operation to perform',
+ enum: ['get', 'query', 'list_tags'],
+ },
+ },
+ 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'],
+ },
+ },
+ ['search_library_docs']: {
parameters: {
type: 'object',
properties: {
@@ -3247,7 +3453,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- search_online: {
+ ['search_online']: {
parameters: {
type: 'object',
properties: {
@@ -3287,7 +3493,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- search_patterns: {
+ ['search_patterns']: {
parameters: {
type: 'object',
properties: {
@@ -3309,7 +3515,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- set_block_enabled: {
+ ['set_block_enabled']: {
parameters: {
type: 'object',
properties: {
@@ -3331,7 +3537,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- set_environment_variables: {
+ ['set_environment_variables']: {
parameters: {
type: 'object',
properties: {
@@ -3365,7 +3571,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- set_global_workflow_variables: {
+ ['set_global_workflow_variables']: {
parameters: {
type: 'object',
properties: {
@@ -3406,7 +3612,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- superagent: {
+ ['superagent']: {
parameters: {
properties: {
task: {
@@ -3420,7 +3626,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- table: {
+ ['table']: {
parameters: {
properties: {
request: {
@@ -3433,7 +3639,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- update_deployment_version: {
+ ['update_deployment_version']: {
parameters: {
type: 'object',
properties: {
@@ -3462,7 +3668,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- update_scheduled_task_history: {
+ ['update_scheduled_task_history']: {
parameters: {
type: 'object',
properties: {
@@ -3480,7 +3686,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- update_workspace_mcp_server: {
+ ['update_workspace_mcp_server']: {
parameters: {
type: 'object',
properties: {
@@ -3505,56 +3711,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- user_memory: {
- parameters: {
- type: 'object',
- properties: {
- confidence: {
- type: 'number',
- description: 'Confidence level 0-1 (default 1.0 for explicit, 0.8 for inferred)',
- },
- correct_value: {
- type: 'string',
- description:
- "The correct value to replace the wrong one (for 'correct' operation). Requires `key` (the memory to replace).",
- },
- key: {
- type: 'string',
- description: "Unique key for the memory (e.g., 'preferred_model', 'slack_credential')",
- },
- limit: {
- type: 'number',
- description: 'Number of results for search (default 10)',
- },
- memory_type: {
- type: 'string',
- description: "Type of memory: 'preference', 'entity', 'history', or 'correction'",
- enum: ['preference', 'entity', 'history', 'correction'],
- },
- operation: {
- type: 'string',
- description: "Operation: 'add', 'search', 'delete', 'correct', or 'list'",
- enum: ['add', 'search', 'delete', 'correct', 'list'],
- },
- query: {
- type: 'string',
- description: 'Search query to find relevant memories',
- },
- source: {
- type: 'string',
- description: "Source: 'explicit' (user told you) or 'inferred' (you observed)",
- enum: ['explicit', 'inferred'],
- },
- value: {
- type: 'string',
- description: 'Value to remember',
- },
- },
- required: ['operation'],
- },
- resultSchema: undefined,
- },
- user_table: {
+ ['user_table']: {
parameters: {
type: 'object',
properties: {
@@ -3917,7 +4074,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
required: ['success', 'message'],
},
},
- workflow: {
+ ['workflow']: {
parameters: {
properties: {
prompt: {
@@ -3930,7 +4087,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- workspace_file: {
+ ['workspace_file']: {
parameters: {
type: 'object',
properties: {
diff --git a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts
index c5024ee3571..a013bc95844 100644
--- a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts
+++ b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts
@@ -89,6 +89,10 @@ export const TraceAttr = {
ChatArtifactKeys: 'chat.artifact_keys',
ChatArtifactsBytes: 'chat.artifacts_bytes',
ChatAuthType: 'chat.auth_type',
+ ChatBlobBytesOffloaded: 'chat.blob_bytes_offloaded',
+ ChatBlobBytesResolved: 'chat.blob_bytes_resolved',
+ ChatBlobsOffloaded: 'chat.blobs_offloaded',
+ ChatBlobsResolved: 'chat.blobs_resolved',
ChatContextCount: 'chat.context_count',
ChatContextUsage: 'chat.context_usage',
ChatContinuationMessagesBefore: 'chat.continuation.messages_before',
@@ -420,6 +424,7 @@ export const TraceAttr = {
GenAiUsageCacheCreationTokens: 'gen_ai.usage.cache_creation_tokens',
GenAiUsageCacheReadInputTokens: 'gen_ai.usage.cache_read.input_tokens',
GenAiUsageCacheReadTokens: 'gen_ai.usage.cache_read_tokens',
+ GenAiUsageContextTokens: 'gen_ai.usage.context_tokens',
GenAiUsageInputTokens: 'gen_ai.usage.input_tokens',
GenAiUsageOutputTokens: 'gen_ai.usage.output_tokens',
GenAiUsageThinkingTokens: 'gen_ai.usage.thinking_tokens',
@@ -446,9 +451,11 @@ export const TraceAttr = {
KnowledgeBaseId: 'knowledge_base.id',
KnowledgeBaseName: 'knowledge_base.name',
LlmBackend: 'llm.backend',
+ LlmCompactionPause: 'llm.compaction.pause',
LlmErrorStage: 'llm.error_stage',
LlmProtocol: 'llm.protocol',
LlmRequestBodyBytes: 'llm.request.body_bytes',
+ LlmRequestCompactionTrigger: 'llm.request.compaction_trigger',
LlmStreamBytes: 'llm.stream.bytes',
LlmStreamChunks: 'llm.stream.chunks',
LlmStreamFirstChunkBytes: 'llm.stream.first_chunk_bytes',
@@ -716,6 +723,10 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [
'chat.artifact_keys',
'chat.artifacts_bytes',
'chat.auth_type',
+ 'chat.blob_bytes_offloaded',
+ 'chat.blob_bytes_resolved',
+ 'chat.blobs_offloaded',
+ 'chat.blobs_resolved',
'chat.context_count',
'chat.context_usage',
'chat.continuation.messages_before',
@@ -1036,6 +1047,7 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [
'gen_ai.usage.cache_creation_tokens',
'gen_ai.usage.cache_read.input_tokens',
'gen_ai.usage.cache_read_tokens',
+ 'gen_ai.usage.context_tokens',
'gen_ai.usage.input_tokens',
'gen_ai.usage.output_tokens',
'gen_ai.usage.thinking_tokens',
@@ -1062,9 +1074,11 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [
'knowledge_base.id',
'knowledge_base.name',
'llm.backend',
+ 'llm.compaction.pause',
'llm.error_stage',
'llm.protocol',
'llm.request.body_bytes',
+ 'llm.request.compaction_trigger',
'llm.stream.bytes',
'llm.stream.chunks',
'llm.stream.first_chunk_bytes',
diff --git a/apps/sim/lib/copilot/request/tool-call-state.ts b/apps/sim/lib/copilot/request/tool-call-state.ts
index 70433429043..d4bf91e02e0 100644
--- a/apps/sim/lib/copilot/request/tool-call-state.ts
+++ b/apps/sim/lib/copilot/request/tool-call-state.ts
@@ -60,14 +60,30 @@ export function getToolCallTerminalData(
toolCall: Pick
): unknown {
const output = getToolCallStateOutput(toolCall)
+ const failed = !isSuccessfulToolCallStatus(toolCall.status as TerminalToolCallStatus)
+
if (output !== undefined) {
- return output
+ if (!failed) {
+ return output
+ }
+ /**
+ * A failed call must always surface its error in the terminal data — this
+ * is what the model reads on resume. Handlers can fail with an
+ * empty-but-defined output (the app-tool executor's "Tool not found" ships
+ * `output: {}`), and preferring that output rendered failures as bare `{}`,
+ * so the model retried blind instead of reacting to the error.
+ */
+ const error =
+ typeof toolCall.error === 'string' && toolCall.error.length > 0
+ ? toolCall.error
+ : 'Tool failed without an error message'
+ if (output && typeof output === 'object' && !Array.isArray(output)) {
+ return 'error' in output ? output : { ...output, error }
+ }
+ return { output, error }
}
- if (
- toolCall.status === MothershipStreamV1ToolOutcome.success ||
- toolCall.status === MothershipStreamV1ToolOutcome.skipped
- ) {
+ if (!failed) {
return undefined
}
diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts
index f479d5e99b7..0b6e28b28a7 100644
--- a/apps/sim/lib/copilot/request/tools/executor.ts
+++ b/apps/sim/lib/copilot/request/tools/executor.ts
@@ -33,12 +33,13 @@ import {
KnowledgeBase,
MaterializeFile,
Media,
- Research,
Run,
RunBlock,
+ RunCode,
RunFromBlock,
RunWorkflow,
RunWorkflowUntilBlock,
+ Search,
WorkspaceFile,
} from '@/lib/copilot/generated/tool-catalog-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
@@ -225,12 +226,13 @@ const LONG_RUNNING_TOOL_IDS: ReadonlySet = new Set([
RunWorkflow.id,
RunWorkflowUntilBlock.id,
FunctionExecute.id,
+ RunCode.id,
GenerateImage.id,
GenerateAudio.id,
GenerateVideo.id,
Ffmpeg.id,
Media.id,
- Research.id,
+ Search.id,
CrawlWebsite.id,
KnowledgeBase.id,
DownloadToWorkspaceFile.id,
diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts
index b39027e3526..f8f44265e5b 100644
--- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts
+++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts
@@ -42,6 +42,7 @@ import {
RenameWorkflow,
RestoreResource,
RunBlock,
+ RunCode,
RunFromBlock,
RunWorkflow,
RunWorkflowUntilBlock,
@@ -87,6 +88,7 @@ import { executeOAuthGetAuthLink, executeOAuthRequestAccess } from '../tools/han
import { executeGetPlatformActions } from '../tools/handlers/platform'
import { executeOpenResource } from '../tools/handlers/resources'
import { executeRestoreResource } from '../tools/handlers/restore-resource'
+import { executeRunCode } from '../tools/handlers/run-code'
import { executeVfsGlob, executeVfsGrep, executeVfsRead } from '../tools/handlers/vfs'
import {
executeCreateWorkflow,
@@ -188,6 +190,7 @@ function buildHandlerMap(): Record {
[ListIntegrationTools.id]: h(executeListIntegrationTools),
[MaterializeFile.id]: h(executeMaterializeFile),
[FunctionExecute.id]: h(executeFunctionExecute),
+ [RunCode.id]: h(executeRunCode),
...buildServerToolHandlers(),
}
diff --git a/apps/sim/lib/copilot/tools/handlers/run-code.ts b/apps/sim/lib/copilot/tools/handlers/run-code.ts
new file mode 100644
index 00000000000..e27babc4512
--- /dev/null
+++ b/apps/sim/lib/copilot/tools/handlers/run-code.ts
@@ -0,0 +1,31 @@
+import type { ToolExecutionContext, ToolExecutionResult } from '@/lib/copilot/tool-executor/types'
+import { executeFunctionExecute } from '@/lib/copilot/tools/handlers/function-execute'
+
+/**
+ * Compute-only variant of function_execute for info-gathering agents: same
+ * sandbox and inputs, but it must never create or overwrite workspace
+ * resources. The write vectors (outputs.files, outputTable) are rejected here
+ * on top of the Go executor's fail-fast guard; run_code is also absent from
+ * the name-gated output post-processors (OUTPUT_PATH_TOOLS etc.), so even a
+ * leaked arg could not write anything.
+ */
+export async function executeRunCode(
+ params: Record,
+ context: ToolExecutionContext
+): Promise {
+ if ('outputs' in params) {
+ return {
+ success: false,
+ error:
+ 'run_code is compute-only: outputs (workspace file writes) is not available; return the data and report it instead',
+ }
+ }
+ if ('outputTable' in params) {
+ return {
+ success: false,
+ error:
+ 'run_code is compute-only: outputTable (workspace table overwrite) is not available; return the data and report it instead',
+ }
+ }
+ return executeFunctionExecute(params, context)
+}
diff --git a/apps/sim/lib/copilot/tools/server/knowledge/search-knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/search-knowledge-base.ts
new file mode 100644
index 00000000000..8b22ee7d7a4
--- /dev/null
+++ b/apps/sim/lib/copilot/tools/server/knowledge/search-knowledge-base.ts
@@ -0,0 +1,39 @@
+import { SearchKnowledgeBase } from '@/lib/copilot/generated/tool-catalog-v1'
+import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool'
+import { knowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/knowledge-base'
+
+type SearchKnowledgeBaseArgs = {
+ operation: string
+ args?: Record
+}
+
+type SearchKnowledgeBaseResult = {
+ success: boolean
+ message: string
+ data?: any
+}
+
+const READ_OPERATIONS = new Set(['get', 'query', 'list_tags'])
+
+/**
+ * Read-only variant of knowledge_base for info-gathering agents. Copilot
+ * access control is a per-agent tool allowlist, so read-only access gets its
+ * own tool name with its own operation contract — enforced here (where
+ * execution happens) on top of the fail-fast guard in the Go executor.
+ */
+export const searchKnowledgeBaseServerTool: BaseServerTool<
+ SearchKnowledgeBaseArgs,
+ SearchKnowledgeBaseResult
+> = {
+ name: SearchKnowledgeBase.id,
+ async execute(params: SearchKnowledgeBaseArgs, context?: ServerToolContext) {
+ const operation = params?.operation
+ if (!READ_OPERATIONS.has(operation)) {
+ return {
+ success: false,
+ message: `search_knowledge_base is read-only: operation '${operation}' is not available (allowed: get, list_tags, query); mutations go through the knowledge agent's knowledge_base tool`,
+ }
+ }
+ return knowledgeBaseServerTool.execute(params, context)
+ },
+}
diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts
index 7780f97eb73..74824b53f25 100644
--- a/apps/sim/lib/copilot/tools/server/router.ts
+++ b/apps/sim/lib/copilot/tools/server/router.ts
@@ -49,10 +49,12 @@ import { validateGeneratedToolPayload } from '@/lib/copilot/tools/server/generat
import { generateImageServerTool } from '@/lib/copilot/tools/server/image/generate-image'
import { getJobLogsServerTool } from '@/lib/copilot/tools/server/jobs/get-job-logs'
import { knowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/knowledge-base'
+import { searchKnowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/search-knowledge-base'
import { ffmpegServerTool } from '@/lib/copilot/tools/server/media/ffmpeg'
import { generateAudioServerTool } from '@/lib/copilot/tools/server/media/generate-audio'
import { generateVideoServerTool } from '@/lib/copilot/tools/server/media/generate-video'
import { searchOnlineServerTool } from '@/lib/copilot/tools/server/other/search-online'
+import { queryUserTableServerTool } from '@/lib/copilot/tools/server/table/query-user-table'
import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table'
import { getCredentialsServerTool } from '@/lib/copilot/tools/server/user/get-credentials'
import { setEnvironmentVariablesServerTool } from '@/lib/copilot/tools/server/user/set-environment-variables'
@@ -150,8 +152,10 @@ const baseServerToolRegistry: Record = {
[setEnvironmentVariablesServerTool.name]: setEnvironmentVariablesServerTool,
[getCredentialsServerTool.name]: getCredentialsServerTool,
[knowledgeBaseServerTool.name]: knowledgeBaseServerTool,
+ [searchKnowledgeBaseServerTool.name]: searchKnowledgeBaseServerTool,
[enrichmentRunServerTool.name]: enrichmentRunServerTool,
[userTableServerTool.name]: userTableServerTool,
+ [queryUserTableServerTool.name]: queryUserTableServerTool,
[workspaceFileServerTool.name]: workspaceFileServerTool,
[editContentServerTool.name]: editContentServerTool,
[createFileServerTool.name]: createFileServerTool,
diff --git a/apps/sim/lib/copilot/tools/server/table/query-user-table.ts b/apps/sim/lib/copilot/tools/server/table/query-user-table.ts
new file mode 100644
index 00000000000..e2b07176da5
--- /dev/null
+++ b/apps/sim/lib/copilot/tools/server/table/query-user-table.ts
@@ -0,0 +1,51 @@
+import { QueryUserTable } from '@/lib/copilot/generated/tool-catalog-v1'
+import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool'
+import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table'
+
+type QueryUserTableArgs = {
+ operation: string
+ args?: Record
+}
+
+type QueryUserTableResult = {
+ success: boolean
+ message: string
+ data?: any
+}
+
+const READ_OPERATIONS = new Set(['get', 'get_schema', 'get_row', 'query_rows'])
+
+/**
+ * Read-only variant of user_table for info-gathering agents. Copilot access
+ * control is a per-agent tool allowlist, so read-only access gets its own tool
+ * name with its own operation contract — enforced here (where execution
+ * happens) on top of the fail-fast guard in the Go executor. outputPath is
+ * rejected because query_rows exports rows to a workspace file through it.
+ */
+export const queryUserTableServerTool: BaseServerTool = {
+ name: QueryUserTable.id,
+ async execute(params: QueryUserTableArgs, context?: ServerToolContext) {
+ const operation = params?.operation
+ if (!READ_OPERATIONS.has(operation)) {
+ return {
+ success: false,
+ message: `query_user_table is read-only: operation '${operation}' is not available (allowed: get, get_row, get_schema, query_rows); mutations go through the table agent's user_table tool`,
+ }
+ }
+ if (params?.args && 'outputPath' in params.args) {
+ return {
+ success: false,
+ message:
+ 'query_user_table is read-only: outputPath (file export) is not available; digest the rows directly or route exports through the table agent',
+ }
+ }
+ if (params && 'outputPath' in (params as Record)) {
+ return {
+ success: false,
+ message:
+ 'query_user_table is read-only: outputPath (file export) is not available; digest the rows directly or route exports through the table agent',
+ }
+ }
+ return userTableServerTool.execute(params, context)
+ },
+}
diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts
index 5ee25a69959..f9bb09014c0 100644
--- a/apps/sim/lib/copilot/tools/tool-display.ts
+++ b/apps/sim/lib/copilot/tools/tool-display.ts
@@ -74,13 +74,15 @@ function workspaceFileTitle(args: ToolArgs): string {
const TOOL_TITLES: Record = {
read: 'Reading file',
search_library_docs: 'Searching library docs',
- user_memory: 'Accessing memory',
user_table: 'Managing table',
+ run_code: 'Running code',
+ query_user_table: 'Querying table',
workspace_file: 'Editing file',
edit_content: 'Applying file content',
create_workflow: 'Creating workflow',
edit_workflow: 'Editing workflow',
knowledge_base: 'Managing knowledge base',
+ search_knowledge_base: 'Searching knowledge base',
open_resource: 'Opening resource',
generate_image: 'Generating image',
generate_video: 'Generating video',
@@ -97,6 +99,8 @@ const TOOL_TITLES: Record = {
scheduled_task: 'Scheduled Task Agent',
agent: 'Tools Agent',
research: 'Research Agent',
+ scout: 'Scout Agent',
+ search: 'Search Agent',
media: 'Media Agent',
superagent: 'Executing action',
}
diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts
index fedc7d65fec..b9b3d37165c 100644
--- a/packages/emcn/src/components/index.ts
+++ b/packages/emcn/src/components/index.ts
@@ -7,7 +7,7 @@ export {
CalendarDayCell,
type CalendarDayCellProps,
} from './calendar/calendar-day-cell'
-export { Checkbox } from './checkbox/checkbox'
+export { Checkbox, checkboxIconVariants, checkboxVariants } from './checkbox/checkbox'
export {
Chip,
ChipLink,
diff --git a/packages/emcn/src/icons/chevron-left.tsx b/packages/emcn/src/icons/chevron-left.tsx
new file mode 100644
index 00000000000..f79b8280e7f
--- /dev/null
+++ b/packages/emcn/src/icons/chevron-left.tsx
@@ -0,0 +1,28 @@
+import type { SVGProps } from 'react'
+
+/**
+ * ChevronLeft icon component
+ * @param props - SVG properties including className, fill, etc.
+ */
+export function ChevronLeft(props: SVGProps) {
+ return (
+
+
+
+ )
+}
diff --git a/packages/emcn/src/icons/chevron-right.tsx b/packages/emcn/src/icons/chevron-right.tsx
new file mode 100644
index 00000000000..2f2a591ae87
--- /dev/null
+++ b/packages/emcn/src/icons/chevron-right.tsx
@@ -0,0 +1,28 @@
+import type { SVGProps } from 'react'
+
+/**
+ * ChevronRight icon component
+ * @param props - SVG properties including className, fill, etc.
+ */
+export function ChevronRight(props: SVGProps) {
+ return (
+
+
+
+ )
+}
diff --git a/packages/emcn/src/icons/index.ts b/packages/emcn/src/icons/index.ts
index 1af0459200b..9879a0c84e5 100644
--- a/packages/emcn/src/icons/index.ts
+++ b/packages/emcn/src/icons/index.ts
@@ -15,6 +15,8 @@ export { Calendar } from './calendar'
export { Card } from './card'
export { Check } from './check'
export { ChevronDown } from './chevron-down'
+export { ChevronLeft } from './chevron-left'
+export { ChevronRight } from './chevron-right'
export { CircleAlert } from './circle-alert'
export { CircleCheck } from './circle-check'
export { CircleInfo } from './circle-info'