Skip to content

feat: workflow bindings for custom agents#728

Merged
larryro merged 2 commits into
mainfrom
feat/custom-agent-workflow-bindings
Mar 9, 2026
Merged

feat: workflow bindings for custom agents#728
larryro merged 2 commits into
mainfrom
feat/custom-agent-workflow-bindings

Conversation

@larryro
Copy link
Copy Markdown
Collaborator

@larryro larryro commented Mar 9, 2026

Summary

  • Allow custom agents to bind workflows as callable tools, so agents can trigger organization workflows during conversations
  • Generate typed Zod schemas from workflow input definitions, giving agents properly typed tool arguments instead of a generic parameters record
  • Split the contract comparison analyze_differences step into separate text analysis and JSON clause extraction steps for cleaner separation of concerns
  • Includes schema changes, validation, bound workflow tool creation, UI tool selector, and test coverage

Test plan

  • Verify custom agent can be configured with workflow bindings via the tool selector UI
  • Confirm bound workflow tools appear with correct typed arguments in test chat
  • Run existing and new unit tests (create_bound_workflow_tool.test.ts, test_chat.test.ts)
  • Test contract comparison workflow with the split analysis step

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Workflows can now be bound and used as dedicated tools within custom agents
  • Improvements

    • Contract comparison analysis enhanced with structured clause classification and formatted output
    • Better file handling support across all bound tools and workflows
  • Changes

    • Removed image preview dialog from test chat interface

Copy link
Copy Markdown

@greptile-apps greptile-apps Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

larryro added 2 commits March 9, 2026 23:00
Allow custom agents to bind workflows as tools, enabling agents to
trigger organization workflows during conversations. Includes schema
changes, validation, bound workflow tool creation, UI selector, and
test coverage.
…nalysis step

Build proper Zod arg schemas from workflow input definitions instead of a
generic parameters record, giving agents typed tool arguments that match
each workflow's inputs directly. Also split the contract comparison
analyze_differences step into separate text analysis and JSON clause
extraction steps for cleaner separation of concerns.
@larryro larryro force-pushed the feat/custom-agent-workflow-bindings branch from b03af0e to f2ca788 Compare March 9, 2026 15:01
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 9, 2026

📝 Walkthrough

Walkthrough

This PR adds comprehensive workflow bindings support to custom agents, enabling workflows to be bound and executed as dedicated tools. Key changes include: adding a workflowBindings field to the agent schema; creating a createBoundWorkflowTool factory to wrap workflow definitions as executable tools; introducing UI components and hooks for selecting and managing workflow bindings; updating the agent serialization pipeline to include workflow bindings in the config; adding backend validation and queries for available workflows; extending file type helpers for all tools; and removing image preview functionality from test chat components. The example contract comparison workflow was updated with a new classification step.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: workflow bindings for custom agents' directly and clearly describes the main feature addition across the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/custom-agent-workflow-bindings

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@services/platform/app/features/custom-agents/components/test-chat-panel/test-message-list.tsx`:
- Around line 145-149: The image preview behavior was removed causing
fileParts/attachments to be non-clickable; restore a preview path by
reintroducing an onImagePreview handler in this component and passing it into
the shared renderers (e.g., pass onImagePreview to FilePartDisplay and the
attachments renderer) or update the shared components to accept a prop like
clickable={true} that opens the full-size image; update the rendering logic
where message.fileParts and attachments are mapped (references: FilePartDisplay,
message.fileParts, attachments, onImagePreview) so thumbnails remain static but
still open a full-size preview when clicked.

In
`@services/platform/convex/agent_tools/workflows/__tests__/create_bound_workflow_tool.test.ts`:
- Around line 25-30: The mock for createTool currently returns only _handler and
_description so tests can't inspect the Zod args; update the vi.mock
implementation in create_bound_workflow_tool.test.ts to also capture def.args
(e.g., include _args: def.args on the mocked tool) and then add assertions that
use the tool's _args.parse to validate required vs optional fields (for example,
call _args.parse with a valid payload to assert no throw and with a missing
required field to assert it throws) so the suite verifies the generated schema.

In
`@services/platform/convex/agent_tools/workflows/create_bound_workflow_tool.ts`:
- Around line 92-163: The prep and approval creation flow currently only wraps
createWorkflowRunApproval in a try/catch; wrap the entire preparation path
(including the calls that can throw:
ctx.runQuery(internal.wf_definitions.internal_queries.resolveWorkflow) which
yields resolvedWf,
ctx.runQuery(internal.wf_definitions.internal_queries.getStartStepConfig) which
yields startStepConfig, extractInputSchema(startStepConfig) producing
runtimeInputSchema, validateWorkflowInput(args, runtimeInputSchema) producing
validation, and getApprovalThreadId(ctx, currentThreadId) producing threadId) in
a single try/catch around the block that leads into calling
internal.agent_tools.workflows.internal_mutations.createWorkflowRunApproval; in
the catch, do not return raw error.message to the caller—log the error details
internally and return a structured { success: false, message } with a safe,
generic message (e.g., "Failed to prepare workflow run") while preserving
existing failure shapes for validation/ownership/status checks.
- Around line 38-50: The buildArgsSchema function currently returns z.object({})
or z.object(shape) which is non-strict and will strip unknown keys; update both
return sites in buildArgsSchema to use strict mode (e.g., call .strict() on the
created Zod object) so unexpected workflow parameters are rejected before
hitting validateWorkflowInput; keep the existing optional/description handling
for properties and only append .strict() to the z.object(...) results to enforce
failure on extra keys.

In `@services/platform/convex/custom_agents/mutations.ts`:
- Around line 81-105: The validator currently only checks existence and org on
wfDefinitions; extend validateWorkflowBindings so that for each wfId it also
resolves the workflow's active/published version before accepting the binding:
after fetching wf (in validateWorkflowBindings), fetch the active version (e.g.
via wf.activeVersionId or similar field) using ctx.db.get(activeVersionId) and
ensure that the returned version record exists and is in a published/active
state (e.g. version.status === 'active' or version.published === true); if no
active version is found or it is archived/draft, throw an Error indicating the
binding points to a non-active workflow version. Ensure you reference the same
symbols: validateWorkflowBindings, workflowBindings, wfDefinitions, wf (and
wf.activeVersionId / wfVersion) so reviewers can locate the change.

In `@services/platform/convex/custom_agents/queries.ts`:
- Around line 280-319: The getAvailableWorkflows handler currently only checks
authentication via getAuthUserIdentity; before querying wfDefinitions you must
enforce organization access (membership/visibility) the same way other
org-scoped endpoints do: call the shared org-access guard used across the
codebase to validate args.organizationId (e.g., the
requireOrgMember/ensureOrgVisible helper your other endpoints call) after
obtaining authUser and before constructing workflowQuery, and if the check fails
return an empty array or appropriate permission error; keep the rest of the
logic unchanged and ensure the guard uses ctx and authUser to verify access for
the provided organizationId.

In `@services/platform/convex/lib/agent_chat/internal_actions.ts`:
- Around line 205-243: Serial hydration of bound workflows blocks on each
ctx.runQuery; instead parallelize by mapping agentConfig.workflowBindings to
Promise tasks: first call
internal.wf_definitions.internal_queries.getActiveVersionByRoot for all rootIds
with Promise.all, filter out missing or wrong-organization activeVersion results
(emit the same debugLog entries for skipped roots), then for the remaining
activeVersions issue Promise.all of
internal.wf_definitions.internal_queries.getStartStepConfig to fetch
startStepConfig for each activeVersion, call extractInputSchema and
createBoundWorkflowTool for each result, and finally populate workflowExtraTools
using the same toolKey generation (sanitizeWorkflowName + rootId.slice) and emit
the "Built bound workflow tools" debugLog if non-empty; keep references to
agentConfig.workflowBindings, ctx.runQuery, getActiveVersionByRoot,
getStartStepConfig, extractInputSchema, createBoundWorkflowTool,
sanitizeWorkflowName, and workflowExtraTools so reviewers can locate and verify
the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7f38f404-eed7-4ed0-a2df-145d0045ed93

📥 Commits

Reviewing files that changed from the base of the PR and between 44944d4 and b03af0e.

⛔ Files ignored due to path filters (1)
  • services/platform/convex/_generated/api.d.ts is excluded by !**/_generated/**
📒 Files selected for processing (20)
  • examples/workflows/contract-comparison/config.json
  • services/platform/app/features/custom-agents/components/test-chat-panel.tsx
  • services/platform/app/features/custom-agents/components/test-chat-panel/test-message-list.tsx
  • services/platform/app/features/custom-agents/components/tool-selector.tsx
  • services/platform/app/features/custom-agents/hooks/queries.ts
  • services/platform/app/features/custom-agents/hooks/use-test-chat.ts
  • services/platform/app/routes/dashboard/$id/custom-agents/$agentId/tools.tsx
  • services/platform/convex/agent_tools/workflows/__tests__/create_bound_workflow_tool.test.ts
  • services/platform/convex/agent_tools/workflows/create_bound_workflow_tool.ts
  • services/platform/convex/custom_agents/config.ts
  • services/platform/convex/custom_agents/mutations.ts
  • services/platform/convex/custom_agents/queries.ts
  • services/platform/convex/custom_agents/schema.ts
  • services/platform/convex/custom_agents/test_chat.test.ts
  • services/platform/convex/lib/agent_chat/internal_actions.ts
  • services/platform/convex/lib/agent_chat/types.ts
  • services/platform/convex/wf_definitions/internal_queries.ts
  • services/platform/convex/workflows/schema.ts
  • services/platform/lib/shared/file-types.ts
  • services/platform/messages/en.json
💤 Files with no reviewable changes (1)
  • services/platform/app/features/custom-agents/components/test-chat-panel.tsx

Comment on lines 145 to +149
{message.fileParts && message.fileParts.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1">
{message.fileParts.map((part) =>
part.mediaType.startsWith('image/') ? (
<button
key={part.url}
type="button"
aria-label={t('customAgents.testChat.previewImage', {
filename: part.filename || 'Image',
})}
onClick={() =>
onImagePreview(part.url, part.filename || 'Image')
}
className="bg-muted focus:ring-ring size-11 cursor-pointer overflow-hidden rounded-lg bg-cover bg-center bg-no-repeat transition-opacity hover:opacity-90 focus:ring-2 focus:ring-offset-2 focus:outline-none"
>
<Image
src={part.url}
alt={part.filename || 'Image'}
className="size-full object-cover"
width={44}
height={44}
/>
</button>
) : (
<a
key={part.url}
href={part.url}
target="_blank"
rel="noopener noreferrer"
className="bg-muted hover:bg-muted/80 flex max-w-[13.5rem] items-center gap-2 rounded-lg px-2 py-1.5 transition-colors"
>
<DocumentIcon fileName={part.filename || 'file'} />
<div className="flex min-w-0 flex-1 flex-col">
<Text as="div" variant="label" truncate>
{part.filename || 'File'}
</Text>
</div>
</a>
),
)}
{message.fileParts.map((part) => (
<FilePartDisplay key={part.url} filePart={part} />
))}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Restore an open/preview path for image files in test chat.

Lines 147-149 and 172-176 now delegate to shared display components that render images as static thumbnails. Combined with removing onImagePreview, image fileParts and attachments can no longer be opened or inspected at full size from this panel. That is a functional regression for test-chat workflows that rely on validating image inputs/outputs. Please keep a preview handler here or extend the shared file display components with a clickable image variant.

Also applies to: 170-178

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@services/platform/app/features/custom-agents/components/test-chat-panel/test-message-list.tsx`
around lines 145 - 149, The image preview behavior was removed causing
fileParts/attachments to be non-clickable; restore a preview path by
reintroducing an onImagePreview handler in this component and passing it into
the shared renderers (e.g., pass onImagePreview to FilePartDisplay and the
attachments renderer) or update the shared components to accept a prop like
clickable={true} that opens the full-size image; update the rendering logic
where message.fileParts and attachments are mapped (references: FilePartDisplay,
message.fileParts, attachments, onImagePreview) so thumbnails remain static but
still open a full-size preview when clicked.

Comment on lines +25 to +30
vi.mock('@convex-dev/agent', () => ({
createTool: vi.fn((def) => ({
_handler: def.handler,
_description: def.description,
})),
}));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preserve def.args in the mock so the suite can verify the generated schema.

Right now the mock only exposes _handler and _description, so these tests never assert the typed Zod args that this PR is adding. A regression back to a generic/unvalidated parameter record would still pass. Capture def.args and add parse assertions for required vs optional fields.

Suggested test adjustment
 vi.mock('@convex-dev/agent', () => ({
   createTool: vi.fn((def) => ({
     _handler: def.handler,
     _description: def.description,
+    _args: def.args,
   })),
 }));
const argsSchema = (tool as unknown as {
  _args: { parse: (value: unknown) => unknown };
})._args;

expect(() => argsSchema.parse({ targetFolder: '/invoices' })).not.toThrow();
expect(() => argsSchema.parse({ daysBack: 30 })).toThrow();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
vi.mock('@convex-dev/agent', () => ({
createTool: vi.fn((def) => ({
_handler: def.handler,
_description: def.description,
})),
}));
vi.mock('@convex-dev/agent', () => ({
createTool: vi.fn((def) => ({
_handler: def.handler,
_description: def.description,
_args: def.args,
})),
}));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@services/platform/convex/agent_tools/workflows/__tests__/create_bound_workflow_tool.test.ts`
around lines 25 - 30, The mock for createTool currently returns only _handler
and _description so tests can't inspect the Zod args; update the vi.mock
implementation in create_bound_workflow_tool.test.ts to also capture def.args
(e.g., include _args: def.args on the mocked tool) and then add assertions that
use the tool's _args.parse to validate required vs optional fields (for example,
call _args.parse with a valid payload to assert no throw and with a missing
required field to assert it throws) so the suite verifies the generated schema.

Comment on lines +38 to +50
function buildArgsSchema(inputSchema: WorkflowInputSchema | undefined) {
if (!inputSchema || Object.keys(inputSchema.properties).length === 0) {
return z.object({});
}

const shape: Record<string, z.ZodTypeAny> = {};
for (const [name, prop] of Object.entries(inputSchema.properties)) {
let field = (ZOD_TYPE_MAP[prop.type] ?? (() => z.unknown()))();
if (prop.description) field = field.describe(prop.description);
if (!inputSchema.required?.includes(name)) field = field.optional();
shape[name] = field;
}
return z.object(shape);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the full file to understand context
fd -t f "create_bound_workflow_tool.ts" --exec cat -n {} \;

Repository: tale-project/tale

Length of output: 8215


🏁 Script executed:

# Check for validateWorkflowInput implementation
rg "validateWorkflowInput" -B 3 -A 10 --max-count 5

Repository: tale-project/tale

Length of output: 13806


🏁 Script executed:

# Check Zod usage patterns in the codebase, specifically with .strict()
rg "\.strict\(\)" -B 2 -A 2 --type ts --max-count 10

Repository: tale-project/tale

Length of output: 43


🏁 Script executed:

# Look for other z.object patterns in agent_tools or workflow contexts
rg "z\.object" -B 2 -A 2 services/platform/convex/agent_tools --type ts

Repository: tale-project/tale

Length of output: 20463


🌐 Web query:

Zod v4 z.object default behavior unknown keys strip or passthrough

💡 Result:

In Zod v4, z.object({...}) strips unknown keys by default (i.e., extra properties are removed from the parsed output). (odocs-zod.vercel.app)

To change that behavior:

  • Keep unknown keys: use z.looseObject(shape) (preferred v4 API) or .passthrough() (legacy but still available). (zod.dev)
  • Reject unknown keys (error): use z.strictObject(shape) (preferred v4 API) or .strict() (legacy). (zod.dev)
  • Reset to default strip: .strip() exists but is deprecated/legacy in v4 (since strip is already the default). (zod.dev)

Citations:


Add .strict() to reject unexpected workflow parameters before they reach downstream handlers.

z.object(shape) strips unknown keys by default in Zod v4, which means hallucinated or misspelled optional parameters silently disappear before validateWorkflowInput() can flag them. This allows incomplete intent to pass validation. Apply strict mode to fail early when the LLM provides unexpected arguments.

Suggested fix
 function buildArgsSchema(inputSchema: WorkflowInputSchema | undefined) {
   if (!inputSchema || Object.keys(inputSchema.properties).length === 0) {
-    return z.object({});
+    return z.object({}).strict();
   }
 
   const shape: Record<string, z.ZodTypeAny> = {};
   for (const [name, prop] of Object.entries(inputSchema.properties)) {
     let field = (ZOD_TYPE_MAP[prop.type] ?? (() => z.unknown()))();
     if (prop.description) field = field.describe(prop.description);
     if (!inputSchema.required?.includes(name)) field = field.optional();
     shape[name] = field;
   }
-  return z.object(shape);
+  return z.object(shape).strict();
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function buildArgsSchema(inputSchema: WorkflowInputSchema | undefined) {
if (!inputSchema || Object.keys(inputSchema.properties).length === 0) {
return z.object({});
}
const shape: Record<string, z.ZodTypeAny> = {};
for (const [name, prop] of Object.entries(inputSchema.properties)) {
let field = (ZOD_TYPE_MAP[prop.type] ?? (() => z.unknown()))();
if (prop.description) field = field.describe(prop.description);
if (!inputSchema.required?.includes(name)) field = field.optional();
shape[name] = field;
}
return z.object(shape);
function buildArgsSchema(inputSchema: WorkflowInputSchema | undefined) {
if (!inputSchema || Object.keys(inputSchema.properties).length === 0) {
return z.object({}).strict();
}
const shape: Record<string, z.ZodTypeAny> = {};
for (const [name, prop] of Object.entries(inputSchema.properties)) {
let field = (ZOD_TYPE_MAP[prop.type] ?? (() => z.unknown()))();
if (prop.description) field = field.describe(prop.description);
if (!inputSchema.required?.includes(name)) field = field.optional();
shape[name] = field;
}
return z.object(shape).strict();
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/convex/agent_tools/workflows/create_bound_workflow_tool.ts`
around lines 38 - 50, The buildArgsSchema function currently returns
z.object({}) or z.object(shape) which is non-strict and will strip unknown keys;
update both return sites in buildArgsSchema to use strict mode (e.g., call
.strict() on the created Zod object) so unexpected workflow parameters are
rejected before hitting validateWorkflowInput; keep the existing
optional/description handling for properties and only append .strict() to the
z.object(...) results to enforce failure on extra keys.

Comment on lines +92 to +163
const resolvedWf = await ctx.runQuery(
internal.wf_definitions.internal_queries.resolveWorkflow,
{ wfDefinitionId: toId<'wfDefinitions'>(String(wfDefinition._id)) },
);

if (!resolvedWf) {
return {
success: false,
message: `Workflow "${wfDefinition.name}" is no longer available.`,
};
}

if (resolvedWf.organizationId !== organizationId) {
return {
success: false,
message: `Workflow "${wfDefinition.name}" does not belong to the current organization.`,
};
}

if (resolvedWf.status === 'archived') {
return {
success: false,
message: `Workflow "${wfDefinition.name}" is archived and cannot be executed.`,
};
}

const startStepConfig = await ctx.runQuery(
internal.wf_definitions.internal_queries.getStartStepConfig,
{ wfDefinitionId: resolvedWf._id },
);

const runtimeInputSchema = extractInputSchema(startStepConfig);
const validation = validateWorkflowInput(args, runtimeInputSchema);

if (!validation.valid) {
return {
success: false,
message: `Invalid workflow parameters: ${validation.errors.join('; ')}`,
};
}

const threadId = await getApprovalThreadId(ctx, currentThreadId);

try {
const approvalId = await ctx.runMutation(
internal.agent_tools.workflows.internal_mutations
.createWorkflowRunApproval,
{
organizationId,
workflowId: resolvedWf._id,
workflowName: resolvedWf.name,
workflowDescription: resolvedWf.description,
parameters: args,
threadId,
messageId,
},
);

return {
success: true,
requiresApproval: true,
approvalId,
approvalCreated: true,
approvalMessage: `APPROVAL CREATED SUCCESSFULLY: An approval card (ID: ${approvalId}) has been created to run workflow "${resolvedWf.name}". The user must approve this in the chat UI before execution begins.`,
message: `Workflow "${resolvedWf.name}" is ready to run. An approval card has been created. The workflow will start once the user approves it.`,
};
} catch (error) {
return {
success: false,
message: `Failed to create workflow run approval: ${
error instanceof Error ? error.message : 'Unknown error'
}`,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle the full preparation path inside the failure boundary.

Only createWorkflowRunApproval is wrapped in try/catch. If resolveWorkflow, getStartStepConfig, extractInputSchema, or getApprovalThreadId throws, this escapes as an unhandled tool error instead of the structured { success: false, message } contract you're returning elsewhere. Expanding the catch also lets you stop echoing raw backend exception text back into chat.

Suggested fix
-      const resolvedWf = await ctx.runQuery(
-        internal.wf_definitions.internal_queries.resolveWorkflow,
-        { wfDefinitionId: toId<'wfDefinitions'>(String(wfDefinition._id)) },
-      );
-
-      if (!resolvedWf) {
-        return {
-          success: false,
-          message: `Workflow "${wfDefinition.name}" is no longer available.`,
-        };
-      }
-
-      if (resolvedWf.organizationId !== organizationId) {
-        return {
-          success: false,
-          message: `Workflow "${wfDefinition.name}" does not belong to the current organization.`,
-        };
-      }
-
-      if (resolvedWf.status === 'archived') {
-        return {
-          success: false,
-          message: `Workflow "${wfDefinition.name}" is archived and cannot be executed.`,
-        };
-      }
-
-      const startStepConfig = await ctx.runQuery(
-        internal.wf_definitions.internal_queries.getStartStepConfig,
-        { wfDefinitionId: resolvedWf._id },
-      );
-
-      const runtimeInputSchema = extractInputSchema(startStepConfig);
-      const validation = validateWorkflowInput(args, runtimeInputSchema);
-
-      if (!validation.valid) {
-        return {
-          success: false,
-          message: `Invalid workflow parameters: ${validation.errors.join('; ')}`,
-        };
-      }
-
-      const threadId = await getApprovalThreadId(ctx, currentThreadId);
-
       try {
+        const resolvedWf = await ctx.runQuery(
+          internal.wf_definitions.internal_queries.resolveWorkflow,
+          { wfDefinitionId: toId<'wfDefinitions'>(String(wfDefinition._id)) },
+        );
+
+        if (!resolvedWf) {
+          return {
+            success: false,
+            message: `Workflow "${wfDefinition.name}" is no longer available.`,
+          };
+        }
+
+        if (resolvedWf.organizationId !== organizationId) {
+          return {
+            success: false,
+            message: `Workflow "${wfDefinition.name}" does not belong to the current organization.`,
+          };
+        }
+
+        if (resolvedWf.status === 'archived') {
+          return {
+            success: false,
+            message: `Workflow "${wfDefinition.name}" is archived and cannot be executed.`,
+          };
+        }
+
+        const startStepConfig = await ctx.runQuery(
+          internal.wf_definitions.internal_queries.getStartStepConfig,
+          { wfDefinitionId: resolvedWf._id },
+        );
+
+        const runtimeInputSchema = extractInputSchema(startStepConfig);
+        const validation = validateWorkflowInput(args, runtimeInputSchema);
+
+        if (!validation.valid) {
+          return {
+            success: false,
+            message: `Invalid workflow parameters: ${validation.errors.join('; ')}`,
+          };
+        }
+
+        const threadId = await getApprovalThreadId(ctx, currentThreadId);
+
         const approvalId = await ctx.runMutation(
           internal.agent_tools.workflows.internal_mutations
             .createWorkflowRunApproval,
@@
         return {
           success: true,
@@
         };
-      } catch (error) {
+      } catch (error) {
+        console.error('Failed to prepare workflow run approval', error);
         return {
           success: false,
-          message: `Failed to create workflow run approval: ${
-            error instanceof Error ? error.message : 'Unknown error'
-          }`,
+          message:
+            'Failed to prepare the workflow run approval. Please try again.',
         };
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const resolvedWf = await ctx.runQuery(
internal.wf_definitions.internal_queries.resolveWorkflow,
{ wfDefinitionId: toId<'wfDefinitions'>(String(wfDefinition._id)) },
);
if (!resolvedWf) {
return {
success: false,
message: `Workflow "${wfDefinition.name}" is no longer available.`,
};
}
if (resolvedWf.organizationId !== organizationId) {
return {
success: false,
message: `Workflow "${wfDefinition.name}" does not belong to the current organization.`,
};
}
if (resolvedWf.status === 'archived') {
return {
success: false,
message: `Workflow "${wfDefinition.name}" is archived and cannot be executed.`,
};
}
const startStepConfig = await ctx.runQuery(
internal.wf_definitions.internal_queries.getStartStepConfig,
{ wfDefinitionId: resolvedWf._id },
);
const runtimeInputSchema = extractInputSchema(startStepConfig);
const validation = validateWorkflowInput(args, runtimeInputSchema);
if (!validation.valid) {
return {
success: false,
message: `Invalid workflow parameters: ${validation.errors.join('; ')}`,
};
}
const threadId = await getApprovalThreadId(ctx, currentThreadId);
try {
const approvalId = await ctx.runMutation(
internal.agent_tools.workflows.internal_mutations
.createWorkflowRunApproval,
{
organizationId,
workflowId: resolvedWf._id,
workflowName: resolvedWf.name,
workflowDescription: resolvedWf.description,
parameters: args,
threadId,
messageId,
},
);
return {
success: true,
requiresApproval: true,
approvalId,
approvalCreated: true,
approvalMessage: `APPROVAL CREATED SUCCESSFULLY: An approval card (ID: ${approvalId}) has been created to run workflow "${resolvedWf.name}". The user must approve this in the chat UI before execution begins.`,
message: `Workflow "${resolvedWf.name}" is ready to run. An approval card has been created. The workflow will start once the user approves it.`,
};
} catch (error) {
return {
success: false,
message: `Failed to create workflow run approval: ${
error instanceof Error ? error.message : 'Unknown error'
}`,
try {
const resolvedWf = await ctx.runQuery(
internal.wf_definitions.internal_queries.resolveWorkflow,
{ wfDefinitionId: toId<'wfDefinitions'>(String(wfDefinition._id)) },
);
if (!resolvedWf) {
return {
success: false,
message: `Workflow "${wfDefinition.name}" is no longer available.`,
};
}
if (resolvedWf.organizationId !== organizationId) {
return {
success: false,
message: `Workflow "${wfDefinition.name}" does not belong to the current organization.`,
};
}
if (resolvedWf.status === 'archived') {
return {
success: false,
message: `Workflow "${wfDefinition.name}" is archived and cannot be executed.`,
};
}
const startStepConfig = await ctx.runQuery(
internal.wf_definitions.internal_queries.getStartStepConfig,
{ wfDefinitionId: resolvedWf._id },
);
const runtimeInputSchema = extractInputSchema(startStepConfig);
const validation = validateWorkflowInput(args, runtimeInputSchema);
if (!validation.valid) {
return {
success: false,
message: `Invalid workflow parameters: ${validation.errors.join('; ')}`,
};
}
const threadId = await getApprovalThreadId(ctx, currentThreadId);
const approvalId = await ctx.runMutation(
internal.agent_tools.workflows.internal_mutations
.createWorkflowRunApproval,
{
organizationId,
workflowId: resolvedWf._id,
workflowName: resolvedWf.name,
workflowDescription: resolvedWf.description,
parameters: args,
threadId,
messageId,
},
);
return {
success: true,
requiresApproval: true,
approvalId,
approvalCreated: true,
approvalMessage: `APPROVAL CREATED SUCCESSFULLY: An approval card (ID: ${approvalId}) has been created to run workflow "${resolvedWf.name}". The user must approve this in the chat UI before execution begins.`,
message: `Workflow "${resolvedWf.name}" is ready to run. An approval card has been created. The workflow will start once the user approves it.`,
};
} catch (error) {
console.error('Failed to prepare workflow run approval', error);
return {
success: false,
message:
'Failed to prepare the workflow run approval. Please try again.',
};
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/convex/agent_tools/workflows/create_bound_workflow_tool.ts`
around lines 92 - 163, The prep and approval creation flow currently only wraps
createWorkflowRunApproval in a try/catch; wrap the entire preparation path
(including the calls that can throw:
ctx.runQuery(internal.wf_definitions.internal_queries.resolveWorkflow) which
yields resolvedWf,
ctx.runQuery(internal.wf_definitions.internal_queries.getStartStepConfig) which
yields startStepConfig, extractInputSchema(startStepConfig) producing
runtimeInputSchema, validateWorkflowInput(args, runtimeInputSchema) producing
validation, and getApprovalThreadId(ctx, currentThreadId) producing threadId) in
a single try/catch around the block that leads into calling
internal.agent_tools.workflows.internal_mutations.createWorkflowRunApproval; in
the catch, do not return raw error.message to the caller—log the error details
internally and return a structured { success: false, message } with a safe,
generic message (e.g., "Failed to prepare workflow run") while preserving
existing failure shapes for validation/ownership/status checks.

Comment on lines +81 to +105
async function validateWorkflowBindings(
ctx: MutationCtx,
workflowBindings: Id<'wfDefinitions'>[] | undefined,
organizationId: string,
) {
if (!workflowBindings?.length) return;

if (workflowBindings.length > MAX_WORKFLOW_BINDINGS) {
throw new Error(
`Cannot bind more than ${MAX_WORKFLOW_BINDINGS} workflows to an agent`,
);
}

for (const wfId of workflowBindings) {
const wf = await ctx.db.get(wfId);
if (!wf) {
throw new Error(`Workflow "${wfId}" not found`);
}
if (wf.organizationId !== organizationId) {
throw new Error(
`Workflow "${wf.name}" does not belong to this organization`,
);
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reject bindings that do not resolve to an active workflow.

This helper only verifies that each wfDefinitions record exists in the same org. The selector/query is returning root ids, though, so a stale or malicious client can still save draft/archived bindings that later fail to materialize as callable tools or get rejected at execution time. Validate that each binding resolves to an active version before persisting it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/convex/custom_agents/mutations.ts` around lines 81 - 105,
The validator currently only checks existence and org on wfDefinitions; extend
validateWorkflowBindings so that for each wfId it also resolves the workflow's
active/published version before accepting the binding: after fetching wf (in
validateWorkflowBindings), fetch the active version (e.g. via wf.activeVersionId
or similar field) using ctx.db.get(activeVersionId) and ensure that the returned
version record exists and is in a published/active state (e.g. version.status
=== 'active' or version.published === true); if no active version is found or it
is archived/draft, throw an Error indicating the binding points to a non-active
workflow version. Ensure you reference the same symbols:
validateWorkflowBindings, workflowBindings, wfDefinitions, wf (and
wf.activeVersionId / wfVersion) so reviewers can locate the change.

Comment on lines +280 to +319
export const getAvailableWorkflows = query({
args: {
organizationId: v.string(),
},
handler: async (
ctx,
args,
): Promise<Array<{ id: string; name: string; description?: string }>> => {
const authUser = await getAuthUserIdentity(ctx);
if (!authUser) return [];

const seen = new Map<
string,
{ id: string; name: string; description?: string; versionNumber: number }
>();

const workflowQuery = ctx.db
.query('wfDefinitions')
.withIndex('by_org_status', (q) =>
q.eq('organizationId', args.organizationId).eq('status', 'active'),
);

for await (const wf of workflowQuery) {
const rootId = String(wf.rootVersionId ?? wf._id);
const existing = seen.get(rootId);
if (!existing || wf.versionNumber > existing.versionNumber) {
seen.set(rootId, {
id: rootId,
name: wf.name,
description: wf.description,
versionNumber: wf.versionNumber,
});
}
}

return Array.from(seen.values()).map(
({ versionNumber: _, ...rest }) => rest,
);
},
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard getAvailableWorkflows with organization access, not just auth.

Line 288 only checks that the caller is signed in. As written, any authenticated user can pass an arbitrary organizationId and enumerate active workflow names/descriptions for that org. Add the same org-membership/visibility gate your other org-scoped public endpoints use before reading wfDefinitions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/convex/custom_agents/queries.ts` around lines 280 - 319,
The getAvailableWorkflows handler currently only checks authentication via
getAuthUserIdentity; before querying wfDefinitions you must enforce organization
access (membership/visibility) the same way other org-scoped endpoints do: call
the shared org-access guard used across the codebase to validate
args.organizationId (e.g., the requireOrgMember/ensureOrgVisible helper your
other endpoints call) after obtaining authUser and before constructing
workflowQuery, and if the check fails return an empty array or appropriate
permission error; keep the rest of the logic unchanged and ensure the guard uses
ctx and authUser to verify access for the provided organizationId.

Comment on lines +205 to +243
// Build bound workflow tools eagerly
let workflowExtraTools: Record<string, unknown> | undefined;
if (agentConfig.workflowBindings?.length) {
workflowExtraTools = {};
for (const rootId of agentConfig.workflowBindings) {
const activeVersion = await ctx.runQuery(
internal.wf_definitions.internal_queries.getActiveVersionByRoot,
{ rootVersionId: toId<'wfDefinitions'>(rootId) },
);

if (!activeVersion) {
debugLog('Skipping bound workflow (no active version)', { rootId });
continue;
}

if (activeVersion.organizationId !== organizationId) {
debugLog('Skipping bound workflow (wrong org)', { rootId });
continue;
}

const startStepConfig = await ctx.runQuery(
internal.wf_definitions.internal_queries.getStartStepConfig,
{ wfDefinitionId: activeVersion._id },
);
const inputSchema = extractInputSchema(startStepConfig);

const toolKey = `workflow_${sanitizeWorkflowName(activeVersion.name)}_${rootId.slice(0, 6)}`;
workflowExtraTools[toolKey] = createBoundWorkflowTool(
activeVersion,
inputSchema,
);
}

if (Object.keys(workflowExtraTools).length > 0) {
debugLog('Built bound workflow tools', {
names: Object.keys(workflowExtraTools),
});
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Parallelize bound-workflow hydration.

Line 210 and Line 225 are inside a serial loop, so each binding adds two blocking internal round trips before generation can start. On the chat hot path that directly increases first-token latency for every turn with multiple workflow bindings.

⚡ Proposed refactor
-    if (agentConfig.workflowBindings?.length) {
-      workflowExtraTools = {};
-      for (const rootId of agentConfig.workflowBindings) {
-        const activeVersion = await ctx.runQuery(
-          internal.wf_definitions.internal_queries.getActiveVersionByRoot,
-          { rootVersionId: toId<'wfDefinitions'>(rootId) },
-        );
-
-        if (!activeVersion) {
-          debugLog('Skipping bound workflow (no active version)', { rootId });
-          continue;
-        }
-
-        if (activeVersion.organizationId !== organizationId) {
-          debugLog('Skipping bound workflow (wrong org)', { rootId });
-          continue;
-        }
-
-        const startStepConfig = await ctx.runQuery(
-          internal.wf_definitions.internal_queries.getStartStepConfig,
-          { wfDefinitionId: activeVersion._id },
-        );
-        const inputSchema = extractInputSchema(startStepConfig);
-
-        const toolKey = `workflow_${sanitizeWorkflowName(activeVersion.name)}_${rootId.slice(0, 6)}`;
-        workflowExtraTools[toolKey] = createBoundWorkflowTool(
-          activeVersion,
-          inputSchema,
-        );
-      }
+    if (agentConfig.workflowBindings?.length) {
+      const workflowEntries = await Promise.all(
+        agentConfig.workflowBindings.map(async (rootId) => {
+          const activeVersion = await ctx.runQuery(
+            internal.wf_definitions.internal_queries.getActiveVersionByRoot,
+            { rootVersionId: toId<'wfDefinitions'>(rootId) },
+          );
+
+          if (!activeVersion) {
+            debugLog('Skipping bound workflow (no active version)', { rootId });
+            return null;
+          }
+
+          if (activeVersion.organizationId !== organizationId) {
+            debugLog('Skipping bound workflow (wrong org)', { rootId });
+            return null;
+          }
+
+          const startStepConfig = await ctx.runQuery(
+            internal.wf_definitions.internal_queries.getStartStepConfig,
+            { wfDefinitionId: activeVersion._id },
+          );
+          const inputSchema = extractInputSchema(startStepConfig);
+          const toolKey = `workflow_${sanitizeWorkflowName(activeVersion.name)}_${rootId.slice(0, 6)}`;
+
+          return [
+            toolKey,
+            createBoundWorkflowTool(activeVersion, inputSchema),
+          ] as const;
+        }),
+      );
+
+      workflowExtraTools = Object.fromEntries(
+        workflowEntries.filter(
+          (entry): entry is readonly [string, unknown] => entry !== null,
+        ),
+      );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/convex/lib/agent_chat/internal_actions.ts` around lines 205
- 243, Serial hydration of bound workflows blocks on each ctx.runQuery; instead
parallelize by mapping agentConfig.workflowBindings to Promise tasks: first call
internal.wf_definitions.internal_queries.getActiveVersionByRoot for all rootIds
with Promise.all, filter out missing or wrong-organization activeVersion results
(emit the same debugLog entries for skipped roots), then for the remaining
activeVersions issue Promise.all of
internal.wf_definitions.internal_queries.getStartStepConfig to fetch
startStepConfig for each activeVersion, call extractInputSchema and
createBoundWorkflowTool for each result, and finally populate workflowExtraTools
using the same toolKey generation (sanitizeWorkflowName + rootId.slice) and emit
the "Built bound workflow tools" debugLog if non-empty; keep references to
agentConfig.workflowBindings, ctx.runQuery, getActiveVersionByRoot,
getStartStepConfig, extractInputSchema, createBoundWorkflowTool,
sanitizeWorkflowName, and workflowExtraTools so reviewers can locate and verify
the change.

@larryro larryro merged commit 8abb845 into main Mar 9, 2026
15 checks passed
@larryro larryro deleted the feat/custom-agent-workflow-bindings branch March 9, 2026 15:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant