diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8b8ba0146..e3b95c834 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -27,10 +27,11 @@ jobs:
steps:
- name: Extract version from commit message
id: extract
+ env:
+ COMMIT_MSG: ${{ github.event.head_commit.message }}
run: |
- COMMIT_MSG="${{ github.event.head_commit.message }}"
# Only tag versions on main branch
- if [ "${{ github.ref }}" = "refs/heads/main" ] && [[ "$COMMIT_MSG" =~ ^(v[0-9]+\.[0-9]+\.[0-9]+): ]]; then
+ if [ "$GITHUB_REF" = "refs/heads/main" ] && [[ "$COMMIT_MSG" =~ ^(v[0-9]+\.[0-9]+\.[0-9]+): ]]; then
VERSION="${BASH_REMATCH[1]}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "is_release=true" >> $GITHUB_OUTPUT
diff --git a/apps/docs/app/global.css b/apps/docs/app/global.css
index 2968717af..53bc8db7f 100644
--- a/apps/docs/app/global.css
+++ b/apps/docs/app/global.css
@@ -119,6 +119,19 @@ aside#nd-sidebar {
}
}
+/* Hide TOC popover on tablet/medium screens (768px - 1279px) */
+/* Keeps it visible on mobile (<768px) for easy navigation */
+/* Desktop (>=1280px) already hides it via fumadocs xl:hidden */
+@media (min-width: 768px) and (max-width: 1279px) {
+ #nd-docs-layout {
+ --fd-toc-popover-height: 0px !important;
+ }
+
+ [data-toc-popover] {
+ display: none !important;
+ }
+}
+
/* Desktop only: Apply custom navbar offset, sidebar width and margin offsets */
/* On mobile, let fumadocs handle the layout natively */
@media (min-width: 1024px) {
diff --git a/apps/docs/content/docs/en/copilot/index.mdx b/apps/docs/content/docs/en/copilot/index.mdx
index fdcdad8d5..e222d8e55 100644
--- a/apps/docs/content/docs/en/copilot/index.mdx
+++ b/apps/docs/content/docs/en/copilot/index.mdx
@@ -5,45 +5,25 @@ title: Copilot
import { Callout } from 'fumadocs-ui/components/callout'
import { Card, Cards } from 'fumadocs-ui/components/card'
import { Image } from '@/components/ui/image'
-import { MessageCircle, Package, Zap, Infinity as InfinityIcon, Brain, BrainCircuit } from 'lucide-react'
+import { MessageCircle, Hammer, Zap, Globe, Paperclip, History, RotateCcw, Brain } from 'lucide-react'
-Copilot is your in-editor assistant that helps you build and edit workflows with Sim Copilot, as well as understand and improve them. It can:
+Copilot is your in-editor assistant that helps you build and edit workflows. It can:
- **Explain**: Answer questions about Sim and your current workflow
- **Guide**: Suggest edits and best practices
-- **Edit**: Make changes to blocks, connections, and settings when you approve
+- **Build**: Add blocks, wire connections, and configure settings
+- **Debug**: Analyze execution issues and optimize performance
- Copilot is a Sim-managed service. For self-hosted deployments, generate a Copilot API key in the hosted app (sim.ai → Settings → Copilot)
+ Copilot is a Sim-managed service. For self-hosted deployments:
1. Go to [sim.ai](https://sim.ai) → Settings → Copilot and generate a Copilot API key
- 2. Set `COPILOT_API_KEY` in your self-hosted environment to that value
+ 2. Set `COPILOT_API_KEY` in your self-hosted environment
-## Context Menu (@)
-
-Use the `@` symbol to reference various resources and give Copilot more context about your workspace:
-
-
-
-The `@` menu provides access to:
-- **Chats**: Reference previous copilot conversations
-- **All workflows**: Reference any workflow in your workspace
-- **Workflow Blocks**: Reference specific blocks from workflows
-- **Blocks**: Reference block types and templates
-- **Knowledge**: Reference your uploaded documents and knowledgebase
-- **Docs**: Reference Sim documentation
-- **Templates**: Reference workflow templates
-- **Logs**: Reference execution logs and results
-
-This contextual information helps Copilot provide more accurate and relevant assistance for your specific use case.
-
## Modes
+Switch between modes using the mode selector at the bottom of the input area.
+
-
- Agent
+
+ Build
}
>
- Build-and-edit mode. Copilot proposes specific edits (add blocks, wire variables, tweak settings) and applies them when you approve.
+ Workflow building mode. Copilot can add blocks, wire connections, edit configurations, and debug issues.
-
-
-
+## Models
-## Depth Levels
+Select your preferred AI model using the model selector at the bottom right of the input area.
-
-
-
- Fast
-
- }
- >
-
Quickest and cheapest. Best for small edits, simple workflows, and minor tweaks.
-
-
-
- Auto
-
- }
- >
-
Balanced speed and reasoning. Recommended default for most tasks.
-
-
-
- Advanced
-
- }
- >
-
More reasoning for larger workflows and complex edits while staying performant.
-
-
-
- Behemoth
-
- }
- >
-
Maximum reasoning for deep planning, debugging, and complex architectural changes.
-
-
+**Available Models:**
+- Claude 4.5 Opus, Sonnet (default), Haiku
+- GPT 5.2 Codex, Pro
+- Gemini 3 Pro
-### Mode Selection Interface
+Choose based on your needs: faster models for simple tasks, more capable models for complex workflows.
-You can easily switch between different reasoning modes using the mode selector in the Copilot interface:
+## Context Menu (@)
-
+Use the `@` symbol to reference resources and give Copilot more context:
-The interface allows you to:
-- **Select reasoning level**: Choose from Fast, Auto, Advanced, or Behemoth
-- **Enable MAX mode**: Toggle for maximum reasoning capabilities when you need the most thorough analysis
-- **See mode descriptions**: Understand what each mode is optimized for
+| Reference | Description |
+|-----------|-------------|
+| **Chats** | Previous copilot conversations |
+| **Workflows** | Any workflow in your workspace |
+| **Workflow Blocks** | Blocks in the current workflow |
+| **Blocks** | Block types and templates |
+| **Knowledge** | Uploaded documents and knowledge bases |
+| **Docs** | Sim documentation |
+| **Templates** | Workflow templates |
+| **Logs** | Execution logs and results |
-Choose your mode based on the complexity of your task - use Fast for simple questions and Behemoth for complex architectural changes.
+Type `@` in the input field to open the context menu, then search or browse to find what you need.
-## Billing and Cost Calculation
+## Slash Commands (/)
-### How Costs Are Calculated
+Use slash commands for quick actions:
-Copilot usage is billed per token from the underlying LLM:
+| Command | Description |
+|---------|-------------|
+| `/fast` | Fast mode execution |
+| `/research` | Research and exploration mode |
+| `/actions` | Execute agent actions |
-- **Input tokens**: billed at the provider's base rate (**at-cost**)
-- **Output tokens**: billed at **1.5×** the provider's base output rate
+**Web Commands:**
-```javascript
-copilotCost = (inputTokens × inputPrice + outputTokens × (outputPrice × 1.5)) / 1,000,000
-```
+| Command | Description |
+|---------|-------------|
+| `/search` | Search the web |
+| `/read` | Read a specific URL |
+| `/scrape` | Scrape web page content |
+| `/crawl` | Crawl multiple pages |
-| Component | Rate Applied |
-|----------|----------------------|
-| Input | inputPrice |
-| Output | outputPrice × 1.5 |
+Type `/` in the input field to see available commands.
-
- Pricing shown reflects rates as of September 4, 2025. Check provider documentation for current pricing.
-
+## Chat Management
+
+### Starting a New Chat
+
+Click the **+** button in the Copilot header to start a fresh conversation.
+
+### Chat History
+
+Click **History** to view previous conversations grouped by date. You can:
+- Click a chat to resume it
+- Delete chats you no longer need
+
+### Editing Messages
+
+Hover over any of your messages and click **Edit** to modify and resend it. This is useful for refining your prompts.
+
+### Message Queue
+
+If you send a message while Copilot is still responding, it gets queued. You can:
+- View queued messages in the expandable queue panel
+- Send a queued message immediately (aborts current response)
+- Remove messages from the queue
+
+## File Attachments
+
+Click the attachment icon to upload files with your message. Supported file types include:
+- Images (preview thumbnails shown)
+- PDFs
+- Text files, JSON, XML
+- Other document formats
+
+Files are displayed as clickable thumbnails that open in a new tab.
+
+## Checkpoints & Changes
+
+When Copilot makes changes to your workflow, it saves checkpoints so you can revert if needed.
+
+### Viewing Checkpoints
+
+Hover over a Copilot message and click the checkpoints icon to see saved workflow states for that message.
+
+### Reverting Changes
+
+Click **Revert** on any checkpoint to restore your workflow to that state. A confirmation dialog will warn that this action cannot be undone.
+
+### Accepting Changes
+
+When Copilot proposes changes, you can:
+- **Accept**: Apply the proposed changes (`Mod+Shift+Enter`)
+- **Reject**: Dismiss the changes and keep your current workflow
+
+## Thinking Blocks
+
+For complex requests, Copilot may show its reasoning process in expandable thinking blocks:
+
+- Blocks auto-expand while Copilot is thinking
+- Click to manually expand/collapse
+- Shows duration of the thinking process
+- Helps you understand how Copilot arrived at its solution
+
+## Options Selection
+
+When Copilot presents multiple options, you can select using:
+
+| Control | Action |
+|---------|--------|
+| **1-9** | Select option by number |
+| **Arrow Up/Down** | Navigate between options |
+| **Enter** | Select highlighted option |
+
+Selected options are highlighted; unselected options appear struck through.
+
+## Keyboard Shortcuts
+
+| Shortcut | Action |
+|----------|--------|
+| `@` | Open context menu |
+| `/` | Open slash commands |
+| `Arrow Up/Down` | Navigate menu items |
+| `Enter` | Select menu item |
+| `Esc` | Close menus |
+| `Mod+Shift+Enter` | Accept Copilot changes |
+
+## Usage Limits
+
+Copilot usage is billed per token from the underlying LLM. If you reach your usage limit, Copilot will prompt you to increase your limit. You can add usage in increments ($50, $100) from your current base.
- Model prices are per million tokens. The calculation divides by 1,000,000 to get the actual cost. See the Cost Calculation page for background and examples.
+ See the [Cost Calculation page](/execution/costs) for billing details.
-
diff --git a/apps/docs/content/docs/en/keyboard-shortcuts/index.mdx b/apps/docs/content/docs/en/keyboard-shortcuts/index.mdx
index 604883c42..94cc831ee 100644
--- a/apps/docs/content/docs/en/keyboard-shortcuts/index.mdx
+++ b/apps/docs/content/docs/en/keyboard-shortcuts/index.mdx
@@ -34,6 +34,8 @@ Speed up your workflow building with these keyboard shortcuts and mouse controls
| `Mod` + `V` | Paste blocks |
| `Delete` or `Backspace` | Delete selected blocks or edges |
| `Shift` + `L` | Auto-layout canvas |
+| `Mod` + `Shift` + `F` | Fit to view |
+| `Mod` + `Shift` + `Enter` | Accept Copilot changes |
## Panel Navigation
diff --git a/apps/docs/content/docs/en/meta.json b/apps/docs/content/docs/en/meta.json
index 52213b66a..b934947a3 100644
--- a/apps/docs/content/docs/en/meta.json
+++ b/apps/docs/content/docs/en/meta.json
@@ -3,6 +3,7 @@
"pages": [
"./introduction/index",
"./getting-started/index",
+ "./quick-reference/index",
"triggers",
"blocks",
"tools",
diff --git a/apps/docs/content/docs/en/quick-reference/index.mdx b/apps/docs/content/docs/en/quick-reference/index.mdx
new file mode 100644
index 000000000..02126a624
--- /dev/null
+++ b/apps/docs/content/docs/en/quick-reference/index.mdx
@@ -0,0 +1,136 @@
+---
+title: Quick Reference
+description: Essential actions for navigating and using the Sim workflow editor
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+
+A quick lookup for everyday actions in the Sim workflow editor. For keyboard shortcuts, see [Keyboard Shortcuts](/keyboard-shortcuts).
+
+
+ **Mod** refers to `Cmd` on macOS and `Ctrl` on Windows/Linux.
+
+
+## Workspaces
+
+| Action | How |
+|--------|-----|
+| Create a workspace | Click workspace dropdown in sidebar → **New Workspace** |
+| Rename a workspace | Workspace settings → Edit name |
+| Switch workspaces | Click workspace dropdown in sidebar → Select workspace |
+| Invite team members | Workspace settings → **Team** → **Invite** |
+
+## Workflows
+
+| Action | How |
+|--------|-----|
+| Create a workflow | Click **New Workflow** button or `Mod+Shift+A` |
+| Rename a workflow | Double-click workflow name in sidebar, or right-click → **Rename** |
+| Duplicate a workflow | Right-click workflow → **Duplicate** |
+| Reorder workflows | Drag workflow up/down in the sidebar list |
+| Import a workflow | Sidebar menu → **Import** → Select file |
+| Create a folder | Right-click in sidebar → **New Folder** |
+| Rename a folder | Right-click folder → **Rename** |
+| Delete a folder | Right-click folder → **Delete** |
+| Collapse/expand folder | Click folder arrow, or double-click folder |
+| Move workflow to folder | Drag workflow onto folder in sidebar |
+| Delete a workflow | Right-click workflow → **Delete** |
+| Export a workflow | Right-click workflow → **Export** |
+| Assign workflow color | Right-click workflow → **Change Color** |
+| Multi-select workflows | `Mod+Click` or `Shift+Click` workflows in sidebar |
+| Open in new tab | Right-click workflow → **Open in New Tab** |
+
+## Blocks
+
+| Action | How |
+|--------|-----|
+| Add a block | Drag from Toolbar panel, or right-click canvas → **Add Block** |
+| Select a block | Click on the block |
+| Multi-select blocks | `Mod+Click` additional blocks, or right-drag to draw selection box |
+| Move blocks | Drag selected block(s) to new position |
+| Copy blocks | `Mod+C` with blocks selected |
+| Paste blocks | `Mod+V` to paste copied blocks |
+| Duplicate blocks | Right-click → **Duplicate** |
+| Delete blocks | `Delete` or `Backspace` key, or right-click → **Delete** |
+| Rename a block | Click block name in header, or edit in the Editor panel |
+| Enable/Disable a block | Right-click → **Enable/Disable** |
+| Toggle handle orientation | Right-click → **Toggle Handles** |
+| Toggle trigger mode | Right-click trigger block → **Toggle Trigger Mode** |
+| Configure a block | Select block → use Editor panel on right |
+
+## Connections
+
+| Action | How |
+|--------|-----|
+| Create a connection | Drag from output handle to input handle |
+| Delete a connection | Click edge to select → `Delete` key |
+| Use output in another block | Drag connection tag into input field |
+
+## Canvas Navigation
+
+| Action | How |
+|--------|-----|
+| Pan/move canvas | Left-drag on empty space, or scroll/trackpad |
+| Zoom in/out | Scroll wheel or pinch gesture |
+| Auto-layout | `Shift+L` |
+| Draw selection box | Right-drag on empty canvas area |
+
+## Panels & Views
+
+| Action | How |
+|--------|-----|
+| Open Copilot tab | Press `C` or click Copilot tab |
+| Open Toolbar tab | Press `T` or click Toolbar tab |
+| Open Editor tab | Press `E` or click Editor tab |
+| Search toolbar | `Mod+F` |
+| Toggle advanced mode | Click toggle button on input fields |
+| Resize panels | Drag panel edge |
+| Collapse/expand sidebar | Click collapse button on sidebar |
+
+## Running & Testing
+
+| Action | How |
+|--------|-----|
+| Run workflow | Click Play button or `Mod+Enter` |
+| Stop workflow | Click Stop button or `Mod+Enter` while running |
+| Test with chat | Use Chat panel on the right side |
+| Select output to view | Click dropdown in Chat panel → Select block output |
+| Clear chat history | Click clear button in Chat panel |
+| View execution logs | Open terminal panel at bottom, or `Mod+L` |
+| Filter logs by block | Click block filter in terminal |
+| Filter logs by status | Click status filter in terminal |
+| Search logs | Use search field in terminal |
+| Copy log entry | Right-click log entry → **Copy** |
+| Clear terminal | `Mod+D` |
+
+## Deployment
+
+| Action | How |
+|--------|-----|
+| Deploy a workflow | Click **Deploy** button in Deploy tab |
+| Update deployment | Click **Update** when changes are detected |
+| View deployment status | Check status indicator (Live/Update/Deploy) in Deploy tab |
+| Revert deployment | Access previous versions in Deploy tab |
+| Copy webhook URL | Deploy tab → Copy webhook URL |
+| Copy API endpoint | Deploy tab → Copy API endpoint URL |
+| Set up a schedule | Add Schedule trigger block → Configure interval |
+
+## Variables
+
+| Action | How |
+|--------|-----|
+| Add workflow variable | Variables tab → **Add Variable** |
+| Edit workflow variable | Variables tab → Click variable to edit |
+| Delete workflow variable | Variables tab → Click delete icon on variable |
+| Add environment variable | Settings → **Environment Variables** → **Add** |
+| Reference a variable | Use `{{variableName}}` syntax in block inputs |
+
+## Credentials
+
+| Action | How |
+|--------|-----|
+| Add API key | Block credential field → **Add Credential** → Enter API key |
+| Connect OAuth account | Block credential field → **Connect** → Authorize with provider |
+| Manage credentials | Settings → **Credentials** |
+| Remove credential | Settings → **Credentials** → Delete credential |
+
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx
index 274bba15b..017a51c0b 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx
@@ -284,22 +284,37 @@ const renderLabel = (
>
)}
{showCanonicalToggle && (
-
+
+
+
+
+
+
+ {canonicalToggle?.mode === 'advanced'
+ ? 'Switch to selector'
+ : 'Switch to manual ID'}
+
+
+
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu.ts
index 5dade163b..9ecfe51f2 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu.ts
@@ -13,7 +13,11 @@ interface UseCanvasContextMenuProps {
/**
* Hook for managing workflow canvas context menus.
- * Handles right-click events, menu state, click-outside detection, and block info extraction.
+ *
+ * Handles right-click events on nodes, pane, and selections with proper multi-select behavior.
+ *
+ * @param props - Hook configuration
+ * @returns Context menu state and handlers
*/
export function useCanvasContextMenu({ blocks, getNodes, setNodes }: UseCanvasContextMenuProps) {
const [activeMenu, setActiveMenu] = useState(null)
@@ -46,19 +50,29 @@ export function useCanvasContextMenu({ blocks, getNodes, setNodes }: UseCanvasCo
event.stopPropagation()
const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey
- setNodes((nodes) =>
- nodes.map((n) => ({
- ...n,
- selected: isMultiSelect ? (n.id === node.id ? true : n.selected) : n.id === node.id,
- }))
- )
+ const currentSelectedNodes = getNodes().filter((n) => n.selected)
+ const isClickedNodeSelected = currentSelectedNodes.some((n) => n.id === node.id)
- const selectedNodes = getNodes().filter((n) => n.selected)
- const nodesToUse = isMultiSelect
- ? selectedNodes.some((n) => n.id === node.id)
- ? selectedNodes
- : [...selectedNodes, node]
- : [node]
+ let nodesToUse: Node[]
+ if (isClickedNodeSelected) {
+ nodesToUse = currentSelectedNodes
+ } else if (isMultiSelect) {
+ nodesToUse = [...currentSelectedNodes, node]
+ setNodes((nodes) =>
+ nodes.map((n) => ({
+ ...n,
+ selected: n.id === node.id ? true : n.selected,
+ }))
+ )
+ } else {
+ nodesToUse = [node]
+ setNodes((nodes) =>
+ nodes.map((n) => ({
+ ...n,
+ selected: n.id === node.id,
+ }))
+ )
+ }
setPosition({ x: event.clientX, y: event.clientY })
setSelectedBlocks(nodesToBlockInfos(nodesToUse))
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-context-menu.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-context-menu.ts
index 35b8546b2..d87a3258d 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-context-menu.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-context-menu.ts
@@ -27,18 +27,13 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) {
const [isOpen, setIsOpen] = useState(false)
const [position, setPosition] = useState({ x: 0, y: 0 })
const menuRef = useRef(null)
- // Used to prevent click-outside dismissal when trigger is clicked
const dismissPreventedRef = useRef(false)
- /**
- * Handle right-click event
- */
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
- // Calculate position relative to viewport
const x = e.clientX
const y = e.clientY
@@ -50,17 +45,10 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) {
[onContextMenu]
)
- /**
- * Close the context menu
- */
const closeMenu = useCallback(() => {
setIsOpen(false)
}, [])
- /**
- * Prevent the next click-outside from dismissing the menu.
- * Call this on pointerdown of a toggle trigger to allow proper toggle behavior.
- */
const preventDismiss = useCallback(() => {
dismissPreventedRef.current = true
}, [])
@@ -72,7 +60,6 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) {
if (!isOpen) return
const handleClickOutside = (e: MouseEvent) => {
- // Check if dismissal was prevented (e.g., by toggle trigger's pointerdown)
if (dismissPreventedRef.current) {
dismissPreventedRef.current = false
return
@@ -82,7 +69,6 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) {
}
}
- // Small delay to prevent immediate close from the same click that opened the menu
const timeoutId = setTimeout(() => {
document.addEventListener('click', handleClickOutside)
}, 0)
diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts
index 34de7723f..1e112d3fc 100644
--- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts
+++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts
@@ -11,9 +11,10 @@ import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { isValidKey } from '@/lib/workflows/sanitization/key-validation'
import { validateWorkflowState } from '@/lib/workflows/sanitization/validation'
+import { buildCanonicalIndex, isCanonicalPair } from '@/lib/workflows/subblocks/visibility'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { getAllBlocks, getBlock } from '@/blocks/registry'
-import type { SubBlockConfig } from '@/blocks/types'
+import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
import { EDGE, normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
@@ -667,11 +668,47 @@ function createBlockFromParams(
}
}
})
+
+ if (validatedInputs) {
+ updateCanonicalModesForInputs(blockState, Object.keys(validatedInputs), blockConfig)
+ }
}
return blockState
}
+function updateCanonicalModesForInputs(
+ block: { data?: { canonicalModes?: Record } },
+ inputKeys: string[],
+ blockConfig: BlockConfig
+): void {
+ if (!blockConfig.subBlocks?.length) return
+
+ const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks)
+ const canonicalModeUpdates: Record = {}
+
+ for (const inputKey of inputKeys) {
+ const canonicalId = canonicalIndex.canonicalIdBySubBlockId[inputKey]
+ if (!canonicalId) continue
+
+ const group = canonicalIndex.groupsById[canonicalId]
+ if (!group || !isCanonicalPair(group)) continue
+
+ const isAdvanced = group.advancedIds.includes(inputKey)
+ const existingMode = canonicalModeUpdates[canonicalId]
+
+ if (!existingMode || isAdvanced) {
+ canonicalModeUpdates[canonicalId] = isAdvanced ? 'advanced' : 'basic'
+ }
+ }
+
+ if (Object.keys(canonicalModeUpdates).length > 0) {
+ if (!block.data) block.data = {}
+ if (!block.data.canonicalModes) block.data.canonicalModes = {}
+ Object.assign(block.data.canonicalModes, canonicalModeUpdates)
+ }
+}
+
/**
* Normalize tools array by adding back fields that were sanitized for training
*/
@@ -1654,6 +1691,15 @@ function applyOperationsToWorkflowState(
block.data.collection = params.inputs.collection
}
}
+
+ const editBlockConfig = getBlock(block.type)
+ if (editBlockConfig) {
+ updateCanonicalModesForInputs(
+ block,
+ Object.keys(validationResult.validInputs),
+ editBlockConfig
+ )
+ }
}
// Update basic properties
@@ -2256,6 +2302,15 @@ function applyOperationsToWorkflowState(
existingBlock.subBlocks[key].value = sanitizedValue
}
})
+
+ const existingBlockConfig = getBlock(existingBlock.type)
+ if (existingBlockConfig) {
+ updateCanonicalModesForInputs(
+ existingBlock,
+ Object.keys(validationResult.validInputs),
+ existingBlockConfig
+ )
+ }
}
} else {
// Special container types (loop, parallel) are not in the block registry but are valid
diff --git a/bun.lock b/bun.lock
index 4faa352e6..f1df7669e 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
- "configVersion": 0,
"workspaces": {
"": {
"name": "simstudio",
diff --git a/packages/python-sdk/.gitignore b/packages/python-sdk/.gitignore
index db9927b25..ec1f692f4 100644
--- a/packages/python-sdk/.gitignore
+++ b/packages/python-sdk/.gitignore
@@ -81,4 +81,7 @@ Thumbs.db
# mypy
.mypy_cache/
.dmypy.json
-dmypy.json
\ No newline at end of file
+dmypy.json
+
+# uv
+uv.lock
\ No newline at end of file
diff --git a/packages/python-sdk/README.md b/packages/python-sdk/README.md
index efcf91df7..e193e951c 100644
--- a/packages/python-sdk/README.md
+++ b/packages/python-sdk/README.md
@@ -43,24 +43,30 @@ SimStudioClient(api_key: str, base_url: str = "https://sim.ai")
#### Methods
-##### execute_workflow(workflow_id, input_data=None, timeout=30.0)
+##### execute_workflow(workflow_id, input=None, *, timeout=30.0, stream=None, selected_outputs=None, async_execution=None)
Execute a workflow with optional input data.
```python
-result = client.execute_workflow(
- "workflow-id",
- input_data={"message": "Hello, world!"},
- timeout=30.0 # 30 seconds
-)
+# With dict input (spread at root level of request body)
+result = client.execute_workflow("workflow-id", {"message": "Hello, world!"})
+
+# With primitive input (wrapped as { input: value })
+result = client.execute_workflow("workflow-id", "NVDA")
+
+# With options (keyword-only arguments)
+result = client.execute_workflow("workflow-id", {"message": "Hello"}, timeout=60.0)
```
**Parameters:**
- `workflow_id` (str): The ID of the workflow to execute
-- `input_data` (dict, optional): Input data to pass to the workflow. File objects are automatically converted to base64.
-- `timeout` (float): Timeout in seconds (default: 30.0)
+- `input` (any, optional): Input data to pass to the workflow. Dicts are spread at the root level, primitives/lists are wrapped in `{ input: value }`. File objects are automatically converted to base64.
+- `timeout` (float, keyword-only): Timeout in seconds (default: 30.0)
+- `stream` (bool, keyword-only): Enable streaming responses
+- `selected_outputs` (list, keyword-only): Block outputs to stream (e.g., `["agent1.content"]`)
+- `async_execution` (bool, keyword-only): Execute asynchronously and return execution ID
-**Returns:** `WorkflowExecutionResult`
+**Returns:** `WorkflowExecutionResult` or `AsyncExecutionResult`
##### get_workflow_status(workflow_id)
@@ -92,24 +98,89 @@ if is_ready:
**Returns:** `bool`
-##### execute_workflow_sync(workflow_id, input_data=None, timeout=30.0)
+##### execute_workflow_sync(workflow_id, input=None, *, timeout=30.0, stream=None, selected_outputs=None)
-Execute a workflow and poll for completion (useful for long-running workflows).
+Execute a workflow synchronously (ensures non-async mode).
```python
-result = client.execute_workflow_sync(
+result = client.execute_workflow_sync("workflow-id", {"data": "some input"}, timeout=60.0)
+```
+
+**Parameters:**
+- `workflow_id` (str): The ID of the workflow to execute
+- `input` (any, optional): Input data to pass to the workflow
+- `timeout` (float, keyword-only): Timeout in seconds (default: 30.0)
+- `stream` (bool, keyword-only): Enable streaming responses
+- `selected_outputs` (list, keyword-only): Block outputs to stream (e.g., `["agent1.content"]`)
+
+**Returns:** `WorkflowExecutionResult`
+
+##### get_job_status(task_id)
+
+Get the status of an async job.
+
+```python
+status = client.get_job_status("task-id-from-async-execution")
+print("Job status:", status)
+```
+
+**Parameters:**
+- `task_id` (str): The task ID returned from async execution
+
+**Returns:** `dict`
+
+##### execute_with_retry(workflow_id, input=None, *, timeout=30.0, stream=None, selected_outputs=None, async_execution=None, max_retries=3, initial_delay=1.0, max_delay=30.0, backoff_multiplier=2.0)
+
+Execute a workflow with automatic retry on rate limit errors.
+
+```python
+result = client.execute_with_retry(
"workflow-id",
- input_data={"data": "some input"},
- timeout=60.0
+ {"message": "Hello"},
+ timeout=30.0,
+ max_retries=3,
+ initial_delay=1.0,
+ max_delay=30.0,
+ backoff_multiplier=2.0
)
```
**Parameters:**
- `workflow_id` (str): The ID of the workflow to execute
-- `input_data` (dict, optional): Input data to pass to the workflow
-- `timeout` (float): Timeout for the initial request in seconds
+- `input` (any, optional): Input data to pass to the workflow
+- `timeout` (float, keyword-only): Timeout in seconds (default: 30.0)
+- `stream` (bool, keyword-only): Enable streaming responses
+- `selected_outputs` (list, keyword-only): Block outputs to stream
+- `async_execution` (bool, keyword-only): Execute asynchronously
+- `max_retries` (int, keyword-only): Maximum retry attempts (default: 3)
+- `initial_delay` (float, keyword-only): Initial delay in seconds (default: 1.0)
+- `max_delay` (float, keyword-only): Maximum delay in seconds (default: 30.0)
+- `backoff_multiplier` (float, keyword-only): Backoff multiplier (default: 2.0)
-**Returns:** `WorkflowExecutionResult`
+**Returns:** `WorkflowExecutionResult` or `AsyncExecutionResult`
+
+##### get_rate_limit_info()
+
+Get current rate limit information from the last API response.
+
+```python
+rate_info = client.get_rate_limit_info()
+if rate_info:
+ print("Remaining requests:", rate_info.remaining)
+```
+
+**Returns:** `RateLimitInfo` or `None`
+
+##### get_usage_limits()
+
+Get current usage limits and quota information.
+
+```python
+limits = client.get_usage_limits()
+print("Current usage:", limits.usage)
+```
+
+**Returns:** `UsageLimits`
##### set_api_key(api_key)
@@ -171,6 +242,39 @@ class SimStudioError(Exception):
self.status = status
```
+### AsyncExecutionResult
+
+```python
+@dataclass
+class AsyncExecutionResult:
+ success: bool
+ task_id: str
+ status: str # 'queued'
+ created_at: str
+ links: Dict[str, str]
+```
+
+### RateLimitInfo
+
+```python
+@dataclass
+class RateLimitInfo:
+ limit: int
+ remaining: int
+ reset: int
+ retry_after: Optional[int] = None
+```
+
+### UsageLimits
+
+```python
+@dataclass
+class UsageLimits:
+ success: bool
+ rate_limit: Dict[str, Any]
+ usage: Dict[str, Any]
+```
+
## Examples
### Basic Workflow Execution
@@ -191,7 +295,7 @@ def run_workflow():
# Execute the workflow
result = client.execute_workflow(
"my-workflow-id",
- input_data={
+ {
"message": "Process this data",
"user_id": "12345"
}
@@ -298,7 +402,7 @@ client = SimStudioClient(api_key=os.getenv("SIM_API_KEY"))
with open('document.pdf', 'rb') as f:
result = client.execute_workflow(
'workflow-id',
- input_data={
+ {
'documents': [f], # Must match your workflow's "files" field name
'instructions': 'Analyze this document'
}
@@ -308,7 +412,7 @@ with open('document.pdf', 'rb') as f:
with open('doc1.pdf', 'rb') as f1, open('doc2.pdf', 'rb') as f2:
result = client.execute_workflow(
'workflow-id',
- input_data={
+ {
'attachments': [f1, f2], # Must match your workflow's "files" field name
'query': 'Compare these documents'
}
@@ -327,14 +431,14 @@ def execute_workflows_batch(workflow_data_pairs):
"""Execute multiple workflows with different input data."""
results = []
- for workflow_id, input_data in workflow_data_pairs:
+ for workflow_id, workflow_input in workflow_data_pairs:
try:
# Validate workflow before execution
if not client.validate_workflow(workflow_id):
print(f"Skipping {workflow_id}: not deployed")
continue
- result = client.execute_workflow(workflow_id, input_data)
+ result = client.execute_workflow(workflow_id, workflow_input)
results.append({
"workflow_id": workflow_id,
"success": result.success,
diff --git a/packages/python-sdk/pyproject.toml b/packages/python-sdk/pyproject.toml
index 850a6b5e7..6812cc1d8 100644
--- a/packages/python-sdk/pyproject.toml
+++ b/packages/python-sdk/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "simstudio-sdk"
-version = "0.1.1"
+version = "0.1.2"
authors = [
{name = "Sim", email = "help@sim.ai"},
]
diff --git a/packages/python-sdk/simstudio/__init__.py b/packages/python-sdk/simstudio/__init__.py
index d20b7d7e9..ec242338e 100644
--- a/packages/python-sdk/simstudio/__init__.py
+++ b/packages/python-sdk/simstudio/__init__.py
@@ -13,7 +13,7 @@ import os
import requests
-__version__ = "0.1.0"
+__version__ = "0.1.2"
__all__ = [
"SimStudioClient",
"SimStudioError",
@@ -64,15 +64,6 @@ class RateLimitInfo:
retry_after: Optional[int] = None
-@dataclass
-class RateLimitStatus:
- """Rate limit status for sync/async requests."""
- is_limited: bool
- limit: int
- remaining: int
- reset_at: str
-
-
@dataclass
class UsageLimits:
"""Usage limits and quota information."""
@@ -115,7 +106,6 @@ class SimStudioClient:
Recursively processes nested dicts and lists.
"""
import base64
- import io
# Check if this is a file-like object
if hasattr(value, 'read') and callable(value.read):
@@ -159,7 +149,8 @@ class SimStudioClient:
def execute_workflow(
self,
workflow_id: str,
- input_data: Optional[Dict[str, Any]] = None,
+ input: Optional[Any] = None,
+ *,
timeout: float = 30.0,
stream: Optional[bool] = None,
selected_outputs: Optional[list] = None,
@@ -169,11 +160,13 @@ class SimStudioClient:
Execute a workflow with optional input data.
If async_execution is True, returns immediately with a task ID.
- File objects in input_data will be automatically detected and converted to base64.
+ File objects in input will be automatically detected and converted to base64.
Args:
workflow_id: The ID of the workflow to execute
- input_data: Input data to pass to the workflow (can include file-like objects)
+ input: Input data to pass to the workflow. Can be a dict (spread at root level),
+ primitive value (string, number, bool), or list (wrapped in 'input' field).
+ File-like objects within dicts are automatically converted to base64.
timeout: Timeout in seconds (default: 30.0)
stream: Enable streaming responses (default: None)
selected_outputs: Block outputs to stream (e.g., ["agent1.content"])
@@ -193,8 +186,15 @@ class SimStudioClient:
headers['X-Execution-Mode'] = 'async'
try:
- # Build JSON body - spread input at root level, then add API control parameters
- body = input_data.copy() if input_data is not None else {}
+ # Build JSON body - spread dict inputs at root level, wrap primitives/lists in 'input' field
+ body = {}
+ if input is not None:
+ if isinstance(input, dict):
+ # Dict input: spread at root level (matches curl/API behavior)
+ body = input.copy()
+ else:
+ # Primitive or list input: wrap in 'input' field
+ body = {'input': input}
# Convert any file objects in the input to base64 format
body = self._convert_files_to_base64(body)
@@ -320,20 +320,18 @@ class SimStudioClient:
def execute_workflow_sync(
self,
workflow_id: str,
- input_data: Optional[Dict[str, Any]] = None,
+ input: Optional[Any] = None,
+ *,
timeout: float = 30.0,
stream: Optional[bool] = None,
selected_outputs: Optional[list] = None
) -> WorkflowExecutionResult:
"""
- Execute a workflow and poll for completion (useful for long-running workflows).
-
- Note: Currently, the API is synchronous, so this method just calls execute_workflow.
- In the future, if async execution is added, this method can be enhanced.
+ Execute a workflow synchronously (ensures non-async mode).
Args:
workflow_id: The ID of the workflow to execute
- input_data: Input data to pass to the workflow (can include file-like objects)
+ input: Input data to pass to the workflow (can include file-like objects)
timeout: Timeout for the initial request in seconds
stream: Enable streaming responses (default: None)
selected_outputs: Block outputs to stream (e.g., ["agent1.content"])
@@ -344,9 +342,14 @@ class SimStudioClient:
Raises:
SimStudioError: If the workflow execution fails
"""
- # For now, the API is synchronous, so we just execute directly
- # In the future, if async execution is added, this method can be enhanced
- return self.execute_workflow(workflow_id, input_data, timeout, stream, selected_outputs)
+ return self.execute_workflow(
+ workflow_id,
+ input,
+ timeout=timeout,
+ stream=stream,
+ selected_outputs=selected_outputs,
+ async_execution=False
+ )
def set_api_key(self, api_key: str) -> None:
"""
@@ -410,7 +413,8 @@ class SimStudioClient:
def execute_with_retry(
self,
workflow_id: str,
- input_data: Optional[Dict[str, Any]] = None,
+ input: Optional[Any] = None,
+ *,
timeout: float = 30.0,
stream: Optional[bool] = None,
selected_outputs: Optional[list] = None,
@@ -425,7 +429,7 @@ class SimStudioClient:
Args:
workflow_id: The ID of the workflow to execute
- input_data: Input data to pass to the workflow (can include file-like objects)
+ input: Input data to pass to the workflow (can include file-like objects)
timeout: Timeout in seconds
stream: Enable streaming responses
selected_outputs: Block outputs to stream
@@ -448,11 +452,11 @@ class SimStudioClient:
try:
return self.execute_workflow(
workflow_id,
- input_data,
- timeout,
- stream,
- selected_outputs,
- async_execution
+ input,
+ timeout=timeout,
+ stream=stream,
+ selected_outputs=selected_outputs,
+ async_execution=async_execution
)
except SimStudioError as e:
if e.code != 'RATE_LIMIT_EXCEEDED':
diff --git a/packages/python-sdk/tests/test_client.py b/packages/python-sdk/tests/test_client.py
index faa3176a9..8dfdee99b 100644
--- a/packages/python-sdk/tests/test_client.py
+++ b/packages/python-sdk/tests/test_client.py
@@ -91,11 +91,9 @@ def test_context_manager(mock_close):
"""Test SimStudioClient as context manager."""
with SimStudioClient(api_key="test-api-key") as client:
assert client.api_key == "test-api-key"
- # Should close without error
mock_close.assert_called_once()
-# Tests for async execution
@patch('simstudio.requests.Session.post')
def test_async_execution_returns_task_id(mock_post):
"""Test async execution returns AsyncExecutionResult."""
@@ -115,7 +113,7 @@ def test_async_execution_returns_task_id(mock_post):
client = SimStudioClient(api_key="test-api-key")
result = client.execute_workflow(
"workflow-id",
- input_data={"message": "Hello"},
+ {"message": "Hello"},
async_execution=True
)
@@ -124,7 +122,6 @@ def test_async_execution_returns_task_id(mock_post):
assert result.status == "queued"
assert result.links["status"] == "/api/jobs/task-123"
- # Verify X-Execution-Mode header was set
call_args = mock_post.call_args
assert call_args[1]["headers"]["X-Execution-Mode"] == "async"
@@ -146,7 +143,7 @@ def test_sync_execution_returns_result(mock_post):
client = SimStudioClient(api_key="test-api-key")
result = client.execute_workflow(
"workflow-id",
- input_data={"message": "Hello"},
+ {"message": "Hello"},
async_execution=False
)
@@ -166,13 +163,12 @@ def test_async_header_not_set_when_false(mock_post):
mock_post.return_value = mock_response
client = SimStudioClient(api_key="test-api-key")
- client.execute_workflow("workflow-id", input_data={"message": "Hello"})
+ client.execute_workflow("workflow-id", {"message": "Hello"})
call_args = mock_post.call_args
assert "X-Execution-Mode" not in call_args[1]["headers"]
-# Tests for job status
@patch('simstudio.requests.Session.get')
def test_get_job_status_success(mock_get):
"""Test getting job status."""
@@ -222,7 +218,6 @@ def test_get_job_status_not_found(mock_get):
assert "Job not found" in str(exc_info.value)
-# Tests for retry with rate limiting
@patch('simstudio.requests.Session.post')
@patch('simstudio.time.sleep')
def test_execute_with_retry_success_first_attempt(mock_sleep, mock_post):
@@ -238,7 +233,7 @@ def test_execute_with_retry_success_first_attempt(mock_sleep, mock_post):
mock_post.return_value = mock_response
client = SimStudioClient(api_key="test-api-key")
- result = client.execute_with_retry("workflow-id", input_data={"message": "test"})
+ result = client.execute_with_retry("workflow-id", {"message": "test"})
assert result.success is True
assert mock_post.call_count == 1
@@ -278,7 +273,7 @@ def test_execute_with_retry_retries_on_rate_limit(mock_sleep, mock_post):
client = SimStudioClient(api_key="test-api-key")
result = client.execute_with_retry(
"workflow-id",
- input_data={"message": "test"},
+ {"message": "test"},
max_retries=3,
initial_delay=0.01
)
@@ -307,7 +302,7 @@ def test_execute_with_retry_max_retries_exceeded(mock_sleep, mock_post):
with pytest.raises(SimStudioError) as exc_info:
client.execute_with_retry(
"workflow-id",
- input_data={"message": "test"},
+ {"message": "test"},
max_retries=2,
initial_delay=0.01
)
@@ -333,13 +328,12 @@ def test_execute_with_retry_no_retry_on_other_errors(mock_post):
client = SimStudioClient(api_key="test-api-key")
with pytest.raises(SimStudioError) as exc_info:
- client.execute_with_retry("workflow-id", input_data={"message": "test"})
+ client.execute_with_retry("workflow-id", {"message": "test"})
assert "Server error" in str(exc_info.value)
assert mock_post.call_count == 1 # No retries
-# Tests for rate limit info
def test_get_rate_limit_info_returns_none_initially():
"""Test rate limit info is None before any API calls."""
client = SimStudioClient(api_key="test-api-key")
@@ -362,7 +356,7 @@ def test_get_rate_limit_info_after_api_call(mock_post):
mock_post.return_value = mock_response
client = SimStudioClient(api_key="test-api-key")
- client.execute_workflow("workflow-id", input_data={})
+ client.execute_workflow("workflow-id", {})
info = client.get_rate_limit_info()
assert info is not None
@@ -371,7 +365,6 @@ def test_get_rate_limit_info_after_api_call(mock_post):
assert info.reset == 1704067200
-# Tests for usage limits
@patch('simstudio.requests.Session.get')
def test_get_usage_limits_success(mock_get):
"""Test getting usage limits."""
@@ -435,7 +428,6 @@ def test_get_usage_limits_unauthorized(mock_get):
assert "Invalid API key" in str(exc_info.value)
-# Tests for streaming with selectedOutputs
@patch('simstudio.requests.Session.post')
def test_execute_workflow_with_stream_and_selected_outputs(mock_post):
"""Test execution with stream and selectedOutputs parameters."""
@@ -449,7 +441,7 @@ def test_execute_workflow_with_stream_and_selected_outputs(mock_post):
client = SimStudioClient(api_key="test-api-key")
client.execute_workflow(
"workflow-id",
- input_data={"message": "test"},
+ {"message": "test"},
stream=True,
selected_outputs=["agent1.content", "agent2.content"]
)
@@ -459,4 +451,85 @@ def test_execute_workflow_with_stream_and_selected_outputs(mock_post):
assert request_body["message"] == "test"
assert request_body["stream"] is True
- assert request_body["selectedOutputs"] == ["agent1.content", "agent2.content"]
\ No newline at end of file
+ assert request_body["selectedOutputs"] == ["agent1.content", "agent2.content"]
+
+
+# Tests for primitive and list inputs
+@patch('simstudio.requests.Session.post')
+def test_execute_workflow_with_string_input(mock_post):
+ """Test execution with primitive string input wraps in input field."""
+ mock_response = Mock()
+ mock_response.ok = True
+ mock_response.status_code = 200
+ mock_response.json.return_value = {"success": True, "output": {}}
+ mock_response.headers.get.return_value = None
+ mock_post.return_value = mock_response
+
+ client = SimStudioClient(api_key="test-api-key")
+ client.execute_workflow("workflow-id", "NVDA")
+
+ call_args = mock_post.call_args
+ request_body = call_args[1]["json"]
+
+ assert request_body["input"] == "NVDA"
+ assert "0" not in request_body # Should not spread string characters
+
+
+@patch('simstudio.requests.Session.post')
+def test_execute_workflow_with_number_input(mock_post):
+ """Test execution with primitive number input wraps in input field."""
+ mock_response = Mock()
+ mock_response.ok = True
+ mock_response.status_code = 200
+ mock_response.json.return_value = {"success": True, "output": {}}
+ mock_response.headers.get.return_value = None
+ mock_post.return_value = mock_response
+
+ client = SimStudioClient(api_key="test-api-key")
+ client.execute_workflow("workflow-id", 42)
+
+ call_args = mock_post.call_args
+ request_body = call_args[1]["json"]
+
+ assert request_body["input"] == 42
+
+
+@patch('simstudio.requests.Session.post')
+def test_execute_workflow_with_list_input(mock_post):
+ """Test execution with list input wraps in input field."""
+ mock_response = Mock()
+ mock_response.ok = True
+ mock_response.status_code = 200
+ mock_response.json.return_value = {"success": True, "output": {}}
+ mock_response.headers.get.return_value = None
+ mock_post.return_value = mock_response
+
+ client = SimStudioClient(api_key="test-api-key")
+ client.execute_workflow("workflow-id", ["NVDA", "AAPL", "GOOG"])
+
+ call_args = mock_post.call_args
+ request_body = call_args[1]["json"]
+
+ assert request_body["input"] == ["NVDA", "AAPL", "GOOG"]
+ assert "0" not in request_body # Should not spread list
+
+
+@patch('simstudio.requests.Session.post')
+def test_execute_workflow_with_dict_input_spreads_at_root(mock_post):
+ """Test execution with dict input spreads at root level."""
+ mock_response = Mock()
+ mock_response.ok = True
+ mock_response.status_code = 200
+ mock_response.json.return_value = {"success": True, "output": {}}
+ mock_response.headers.get.return_value = None
+ mock_post.return_value = mock_response
+
+ client = SimStudioClient(api_key="test-api-key")
+ client.execute_workflow("workflow-id", {"ticker": "NVDA", "quantity": 100})
+
+ call_args = mock_post.call_args
+ request_body = call_args[1]["json"]
+
+ assert request_body["ticker"] == "NVDA"
+ assert request_body["quantity"] == 100
+ assert "input" not in request_body # Should not wrap in input field
\ No newline at end of file
diff --git a/packages/ts-sdk/README.md b/packages/ts-sdk/README.md
index 04582aeda..44d21d0c9 100644
--- a/packages/ts-sdk/README.md
+++ b/packages/ts-sdk/README.md
@@ -47,24 +47,35 @@ new SimStudioClient(config: SimStudioConfig)
#### Methods
-##### executeWorkflow(workflowId, options?)
+##### executeWorkflow(workflowId, input?, options?)
Execute a workflow with optional input data.
```typescript
+// With object input (spread at root level of request body)
const result = await client.executeWorkflow('workflow-id', {
- input: { message: 'Hello, world!' },
- timeout: 30000 // 30 seconds
+ message: 'Hello, world!'
+});
+
+// With primitive input (wrapped as { input: value })
+const result = await client.executeWorkflow('workflow-id', 'NVDA');
+
+// With options
+const result = await client.executeWorkflow('workflow-id', { message: 'Hello' }, {
+ timeout: 60000
});
```
**Parameters:**
- `workflowId` (string): The ID of the workflow to execute
+- `input` (any, optional): Input data to pass to the workflow. Objects are spread at the root level, primitives/arrays are wrapped in `{ input: value }`. File objects are automatically converted to base64.
- `options` (ExecutionOptions, optional):
- - `input` (any): Input data to pass to the workflow. File objects are automatically converted to base64.
- `timeout` (number): Timeout in milliseconds (default: 30000)
+ - `stream` (boolean): Enable streaming responses
+ - `selectedOutputs` (string[]): Block outputs to stream (e.g., `["agent1.content"]`)
+ - `async` (boolean): Execute asynchronously and return execution ID
-**Returns:** `Promise`
+**Returns:** `Promise`
##### getWorkflowStatus(workflowId)
@@ -96,25 +107,89 @@ if (isReady) {
**Returns:** `Promise`
-##### executeWorkflowSync(workflowId, options?)
+##### executeWorkflowSync(workflowId, input?, options?)
Execute a workflow and poll for completion (useful for long-running workflows).
```typescript
-const result = await client.executeWorkflowSync('workflow-id', {
- input: { data: 'some input' },
+const result = await client.executeWorkflowSync('workflow-id', { data: 'some input' }, {
timeout: 60000
});
```
**Parameters:**
- `workflowId` (string): The ID of the workflow to execute
+- `input` (any, optional): Input data to pass to the workflow
- `options` (ExecutionOptions, optional):
- - `input` (any): Input data to pass to the workflow
- `timeout` (number): Timeout for the initial request in milliseconds
**Returns:** `Promise`
+##### getJobStatus(taskId)
+
+Get the status of an async job.
+
+```typescript
+const status = await client.getJobStatus('task-id-from-async-execution');
+console.log('Job status:', status);
+```
+
+**Parameters:**
+- `taskId` (string): The task ID returned from async execution
+
+**Returns:** `Promise`
+
+##### executeWithRetry(workflowId, input?, options?, retryOptions?)
+
+Execute a workflow with automatic retry on rate limit errors.
+
+```typescript
+const result = await client.executeWithRetry('workflow-id', { message: 'Hello' }, {
+ timeout: 30000
+}, {
+ maxRetries: 3,
+ initialDelay: 1000,
+ maxDelay: 30000,
+ backoffMultiplier: 2
+});
+```
+
+**Parameters:**
+- `workflowId` (string): The ID of the workflow to execute
+- `input` (any, optional): Input data to pass to the workflow
+- `options` (ExecutionOptions, optional): Execution options
+- `retryOptions` (RetryOptions, optional):
+ - `maxRetries` (number): Maximum retry attempts (default: 3)
+ - `initialDelay` (number): Initial delay in ms (default: 1000)
+ - `maxDelay` (number): Maximum delay in ms (default: 30000)
+ - `backoffMultiplier` (number): Backoff multiplier (default: 2)
+
+**Returns:** `Promise`
+
+##### getRateLimitInfo()
+
+Get current rate limit information from the last API response.
+
+```typescript
+const rateInfo = client.getRateLimitInfo();
+if (rateInfo) {
+ console.log('Remaining requests:', rateInfo.remaining);
+}
+```
+
+**Returns:** `RateLimitInfo | null`
+
+##### getUsageLimits()
+
+Get current usage limits and quota information.
+
+```typescript
+const limits = await client.getUsageLimits();
+console.log('Current usage:', limits.usage);
+```
+
+**Returns:** `Promise`
+
##### setApiKey(apiKey)
Update the API key.
@@ -170,6 +245,81 @@ class SimStudioError extends Error {
}
```
+### AsyncExecutionResult
+
+```typescript
+interface AsyncExecutionResult {
+ success: boolean;
+ taskId: string;
+ status: 'queued';
+ createdAt: string;
+ links: {
+ status: string;
+ };
+}
+```
+
+### RateLimitInfo
+
+```typescript
+interface RateLimitInfo {
+ limit: number;
+ remaining: number;
+ reset: number;
+ retryAfter?: number;
+}
+```
+
+### UsageLimits
+
+```typescript
+interface UsageLimits {
+ success: boolean;
+ rateLimit: {
+ sync: {
+ isLimited: boolean;
+ limit: number;
+ remaining: number;
+ resetAt: string;
+ };
+ async: {
+ isLimited: boolean;
+ limit: number;
+ remaining: number;
+ resetAt: string;
+ };
+ authType: string;
+ };
+ usage: {
+ currentPeriodCost: number;
+ limit: number;
+ plan: string;
+ };
+}
+```
+
+### ExecutionOptions
+
+```typescript
+interface ExecutionOptions {
+ timeout?: number;
+ stream?: boolean;
+ selectedOutputs?: string[];
+ async?: boolean;
+}
+```
+
+### RetryOptions
+
+```typescript
+interface RetryOptions {
+ maxRetries?: number;
+ initialDelay?: number;
+ maxDelay?: number;
+ backoffMultiplier?: number;
+}
+```
+
## Examples
### Basic Workflow Execution
@@ -191,10 +341,8 @@ async function runWorkflow() {
// Execute the workflow
const result = await client.executeWorkflow('my-workflow-id', {
- input: {
- message: 'Process this data',
- userId: '12345'
- }
+ message: 'Process this data',
+ userId: '12345'
});
if (result.success) {
@@ -298,22 +446,18 @@ const file = new File([fileBuffer], 'document.pdf', { type: 'application/pdf' })
// Include files under the field name from your API trigger's input format
const result = await client.executeWorkflow('workflow-id', {
- input: {
- documents: [file], // Field name must match your API trigger's file input field
- instructions: 'Process this document'
- }
+ documents: [file], // Field name must match your API trigger's file input field
+ instructions: 'Process this document'
});
// Browser: From file input
const handleFileUpload = async (event: Event) => {
- const input = event.target as HTMLInputElement;
- const files = Array.from(input.files || []);
+ const inputEl = event.target as HTMLInputElement;
+ const files = Array.from(inputEl.files || []);
const result = await client.executeWorkflow('workflow-id', {
- input: {
- attachments: files, // Field name must match your API trigger's file input field
- query: 'Analyze these files'
- }
+ attachments: files, // Field name must match your API trigger's file input field
+ query: 'Analyze these files'
});
};
```
diff --git a/packages/ts-sdk/package.json b/packages/ts-sdk/package.json
index f40d69516..649a8b76e 100644
--- a/packages/ts-sdk/package.json
+++ b/packages/ts-sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "simstudio-ts-sdk",
- "version": "0.1.1",
+ "version": "0.1.2",
"description": "Sim SDK - Execute workflows programmatically",
"type": "module",
"exports": {
diff --git a/packages/ts-sdk/src/index.test.ts b/packages/ts-sdk/src/index.test.ts
index e8ebbddad..063a31b32 100644
--- a/packages/ts-sdk/src/index.test.ts
+++ b/packages/ts-sdk/src/index.test.ts
@@ -119,10 +119,11 @@ describe('SimStudioClient', () => {
}
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
- const result = await client.executeWorkflow('workflow-id', {
- input: { message: 'Hello' },
- async: true,
- })
+ const result = await client.executeWorkflow(
+ 'workflow-id',
+ { message: 'Hello' },
+ { async: true }
+ )
expect(result).toHaveProperty('taskId', 'task-123')
expect(result).toHaveProperty('status', 'queued')
@@ -152,10 +153,11 @@ describe('SimStudioClient', () => {
}
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
- const result = await client.executeWorkflow('workflow-id', {
- input: { message: 'Hello' },
- async: false,
- })
+ const result = await client.executeWorkflow(
+ 'workflow-id',
+ { message: 'Hello' },
+ { async: false }
+ )
expect(result).toHaveProperty('success', true)
expect(result).toHaveProperty('output')
@@ -177,9 +179,7 @@ describe('SimStudioClient', () => {
}
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
- await client.executeWorkflow('workflow-id', {
- input: { message: 'Hello' },
- })
+ await client.executeWorkflow('workflow-id', { message: 'Hello' })
const calls = vi.mocked(fetch.default).mock.calls
expect(calls[0][1]?.headers).not.toHaveProperty('X-Execution-Mode')
@@ -256,9 +256,7 @@ describe('SimStudioClient', () => {
}
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
- const result = await client.executeWithRetry('workflow-id', {
- input: { message: 'test' },
- })
+ const result = await client.executeWithRetry('workflow-id', { message: 'test' })
expect(result).toHaveProperty('success', true)
expect(vi.mocked(fetch.default)).toHaveBeenCalledTimes(1)
@@ -305,7 +303,8 @@ describe('SimStudioClient', () => {
const result = await client.executeWithRetry(
'workflow-id',
- { input: { message: 'test' } },
+ { message: 'test' },
+ {},
{ maxRetries: 3, initialDelay: 10 }
)
@@ -336,7 +335,8 @@ describe('SimStudioClient', () => {
await expect(
client.executeWithRetry(
'workflow-id',
- { input: { message: 'test' } },
+ { message: 'test' },
+ {},
{ maxRetries: 2, initialDelay: 10 }
)
).rejects.toThrow('Rate limit exceeded')
@@ -361,9 +361,9 @@ describe('SimStudioClient', () => {
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
- await expect(
- client.executeWithRetry('workflow-id', { input: { message: 'test' } })
- ).rejects.toThrow('Server error')
+ await expect(client.executeWithRetry('workflow-id', { message: 'test' })).rejects.toThrow(
+ 'Server error'
+ )
expect(vi.mocked(fetch.default)).toHaveBeenCalledTimes(1) // No retries
})
@@ -393,7 +393,7 @@ describe('SimStudioClient', () => {
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
- await client.executeWorkflow('workflow-id', { input: {} })
+ await client.executeWorkflow('workflow-id', {})
const info = client.getRateLimitInfo()
expect(info).not.toBeNull()
@@ -490,11 +490,11 @@ describe('SimStudioClient', () => {
vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
- await client.executeWorkflow('workflow-id', {
- input: { message: 'test' },
- stream: true,
- selectedOutputs: ['agent1.content', 'agent2.content'],
- })
+ await client.executeWorkflow(
+ 'workflow-id',
+ { message: 'test' },
+ { stream: true, selectedOutputs: ['agent1.content', 'agent2.content'] }
+ )
const calls = vi.mocked(fetch.default).mock.calls
const requestBody = JSON.parse(calls[0][1]?.body as string)
@@ -505,6 +505,134 @@ describe('SimStudioClient', () => {
expect(requestBody.selectedOutputs).toEqual(['agent1.content', 'agent2.content'])
})
})
+
+ describe('executeWorkflow - primitive and array inputs', () => {
+ it('should wrap primitive string input in input field', async () => {
+ const fetch = await import('node-fetch')
+ const mockResponse = {
+ ok: true,
+ status: 200,
+ json: vi.fn().mockResolvedValue({
+ success: true,
+ output: {},
+ }),
+ headers: {
+ get: vi.fn().mockReturnValue(null),
+ },
+ }
+
+ vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
+
+ await client.executeWorkflow('workflow-id', 'NVDA')
+
+ const calls = vi.mocked(fetch.default).mock.calls
+ const requestBody = JSON.parse(calls[0][1]?.body as string)
+
+ expect(requestBody).toHaveProperty('input', 'NVDA')
+ expect(requestBody).not.toHaveProperty('0') // Should not spread string characters
+ })
+
+ it('should wrap primitive number input in input field', async () => {
+ const fetch = await import('node-fetch')
+ const mockResponse = {
+ ok: true,
+ status: 200,
+ json: vi.fn().mockResolvedValue({
+ success: true,
+ output: {},
+ }),
+ headers: {
+ get: vi.fn().mockReturnValue(null),
+ },
+ }
+
+ vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
+
+ await client.executeWorkflow('workflow-id', 42)
+
+ const calls = vi.mocked(fetch.default).mock.calls
+ const requestBody = JSON.parse(calls[0][1]?.body as string)
+
+ expect(requestBody).toHaveProperty('input', 42)
+ })
+
+ it('should wrap array input in input field', async () => {
+ const fetch = await import('node-fetch')
+ const mockResponse = {
+ ok: true,
+ status: 200,
+ json: vi.fn().mockResolvedValue({
+ success: true,
+ output: {},
+ }),
+ headers: {
+ get: vi.fn().mockReturnValue(null),
+ },
+ }
+
+ vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
+
+ await client.executeWorkflow('workflow-id', ['NVDA', 'AAPL', 'GOOG'])
+
+ const calls = vi.mocked(fetch.default).mock.calls
+ const requestBody = JSON.parse(calls[0][1]?.body as string)
+
+ expect(requestBody).toHaveProperty('input')
+ expect(requestBody.input).toEqual(['NVDA', 'AAPL', 'GOOG'])
+ expect(requestBody).not.toHaveProperty('0') // Should not spread array
+ })
+
+ it('should spread object input at root level', async () => {
+ const fetch = await import('node-fetch')
+ const mockResponse = {
+ ok: true,
+ status: 200,
+ json: vi.fn().mockResolvedValue({
+ success: true,
+ output: {},
+ }),
+ headers: {
+ get: vi.fn().mockReturnValue(null),
+ },
+ }
+
+ vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
+
+ await client.executeWorkflow('workflow-id', { ticker: 'NVDA', quantity: 100 })
+
+ const calls = vi.mocked(fetch.default).mock.calls
+ const requestBody = JSON.parse(calls[0][1]?.body as string)
+
+ expect(requestBody).toHaveProperty('ticker', 'NVDA')
+ expect(requestBody).toHaveProperty('quantity', 100)
+ expect(requestBody).not.toHaveProperty('input') // Should not wrap in input field
+ })
+
+ it('should handle null input as no input (empty body)', async () => {
+ const fetch = await import('node-fetch')
+ const mockResponse = {
+ ok: true,
+ status: 200,
+ json: vi.fn().mockResolvedValue({
+ success: true,
+ output: {},
+ }),
+ headers: {
+ get: vi.fn().mockReturnValue(null),
+ },
+ }
+
+ vi.mocked(fetch.default).mockResolvedValue(mockResponse as any)
+
+ await client.executeWorkflow('workflow-id', null)
+
+ const calls = vi.mocked(fetch.default).mock.calls
+ const requestBody = JSON.parse(calls[0][1]?.body as string)
+
+ // null treated as "no input" - sends empty body (consistent with Python SDK)
+ expect(requestBody).toEqual({})
+ })
+ })
})
describe('SimStudioError', () => {
diff --git a/packages/ts-sdk/src/index.ts b/packages/ts-sdk/src/index.ts
index 2f4c555d8..e6e765786 100644
--- a/packages/ts-sdk/src/index.ts
+++ b/packages/ts-sdk/src/index.ts
@@ -26,7 +26,6 @@ export interface WorkflowStatus {
}
export interface ExecutionOptions {
- input?: any
timeout?: number
stream?: boolean
selectedOutputs?: string[]
@@ -117,10 +116,6 @@ export class SimStudioClient {
this.baseUrl = normalizeBaseUrl(config.baseUrl || 'https://sim.ai')
}
- /**
- * Execute a workflow with optional input data
- * If async is true, returns immediately with a task ID
- */
/**
* Convert File objects in input to API format (base64)
* Recursively processes nested objects and arrays
@@ -170,20 +165,25 @@ export class SimStudioClient {
return value
}
+ /**
+ * Execute a workflow with optional input data
+ * @param workflowId - The ID of the workflow to execute
+ * @param input - Input data to pass to the workflow (object, primitive, or array)
+ * @param options - Execution options (timeout, stream, async, etc.)
+ */
async executeWorkflow(
workflowId: string,
+ input?: any,
options: ExecutionOptions = {}
): Promise {
const url = `${this.baseUrl}/api/workflows/${workflowId}/execute`
- const { input, timeout = 30000, stream, selectedOutputs, async } = options
+ const { timeout = 30000, stream, selectedOutputs, async } = options
try {
- // Create a timeout promise
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('TIMEOUT')), timeout)
})
- // Build headers - async execution uses X-Execution-Mode header
const headers: Record = {
'Content-Type': 'application/json',
'X-API-Key': this.apiKey,
@@ -192,10 +192,15 @@ export class SimStudioClient {
headers['X-Execution-Mode'] = 'async'
}
- // Build JSON body - spread input at root level, then add API control parameters
- let jsonBody: any = input !== undefined ? { ...input } : {}
+ let jsonBody: any = {}
+ if (input !== undefined && input !== null) {
+ if (typeof input === 'object' && input !== null && !Array.isArray(input)) {
+ jsonBody = { ...input }
+ } else {
+ jsonBody = { input }
+ }
+ }
- // Convert any File objects in the input to base64 format
jsonBody = await this.convertFilesToBase64(jsonBody)
if (stream !== undefined) {
@@ -213,10 +218,8 @@ export class SimStudioClient {
const response = await Promise.race([fetchPromise, timeoutPromise])
- // Extract rate limit headers
this.updateRateLimitInfo(response)
- // Handle rate limiting with retry
if (response.status === 429) {
const retryAfter = this.rateLimitInfo?.retryAfter || 1000
throw new SimStudioError(
@@ -285,15 +288,18 @@ export class SimStudioClient {
}
/**
- * Execute a workflow and poll for completion (useful for long-running workflows)
+ * Execute a workflow synchronously (ensures non-async mode)
+ * @param workflowId - The ID of the workflow to execute
+ * @param input - Input data to pass to the workflow
+ * @param options - Execution options (timeout, stream, etc.)
*/
async executeWorkflowSync(
workflowId: string,
+ input?: any,
options: ExecutionOptions = {}
): Promise {
- // Ensure sync mode by explicitly setting async to false
const syncOptions = { ...options, async: false }
- return this.executeWorkflow(workflowId, syncOptions) as Promise
+ return this.executeWorkflow(workflowId, input, syncOptions) as Promise
}
/**
@@ -361,9 +367,14 @@ export class SimStudioClient {
/**
* Execute workflow with automatic retry on rate limit
+ * @param workflowId - The ID of the workflow to execute
+ * @param input - Input data to pass to the workflow
+ * @param options - Execution options (timeout, stream, async, etc.)
+ * @param retryOptions - Retry configuration (maxRetries, delays, etc.)
*/
async executeWithRetry(
workflowId: string,
+ input?: any,
options: ExecutionOptions = {},
retryOptions: RetryOptions = {}
): Promise {
@@ -379,7 +390,7 @@ export class SimStudioClient {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
- return await this.executeWorkflow(workflowId, options)
+ return await this.executeWorkflow(workflowId, input, options)
} catch (error: any) {
if (!(error instanceof SimStudioError) || error.code !== 'RATE_LIMIT_EXCEEDED') {
throw error
@@ -387,23 +398,19 @@ export class SimStudioClient {
lastError = error
- // Don't retry after last attempt
if (attempt === maxRetries) {
break
}
- // Use retry-after if provided, otherwise use exponential backoff
const waitTime =
error.status === 429 && this.rateLimitInfo?.retryAfter
? this.rateLimitInfo.retryAfter
: Math.min(delay, maxDelay)
- // Add jitter (±25%)
const jitter = waitTime * (0.75 + Math.random() * 0.5)
await new Promise((resolve) => setTimeout(resolve, jitter))
- // Exponential backoff for next attempt
delay *= backoffMultiplier
}
}
@@ -475,5 +482,4 @@ export class SimStudioClient {
}
}
-// Export types and classes
export { SimStudioClient as default }
diff --git a/scripts/create-single-release.ts b/scripts/create-single-release.ts
index 3e6d90c56..b49777cec 100755
--- a/scripts/create-single-release.ts
+++ b/scripts/create-single-release.ts
@@ -126,7 +126,7 @@ async function fetchGitHubCommitDetails(
const githubUsername = commit.author?.login || commit.committer?.login || 'unknown'
- let cleanMessage = commit.commit.message.split('\n')[0] // First line only
+ let cleanMessage = commit.commit.message.split('\n')[0]
if (prNumber) {
cleanMessage = cleanMessage.replace(/\s*\(#\d+\)\s*$/, '')
}
@@ -226,12 +226,23 @@ async function getCommitsBetweenVersions(
function categorizeCommit(message: string): 'features' | 'fixes' | 'improvements' | 'other' {
const msgLower = message.toLowerCase()
- if (
- msgLower.includes('feat') ||
- msgLower.includes('add') ||
- msgLower.includes('implement') ||
- msgLower.includes('new ')
- ) {
+ if (/^feat(\(|:|!)/.test(msgLower)) {
+ return 'features'
+ }
+
+ if (/^fix(\(|:|!)/.test(msgLower)) {
+ return 'fixes'
+ }
+
+ if (/^(improvement|improve|perf|refactor)(\(|:|!)/.test(msgLower)) {
+ return 'improvements'
+ }
+
+ if (/^(chore|docs|style|test|ci|build)(\(|:|!)/.test(msgLower)) {
+ return 'other'
+ }
+
+ if (msgLower.includes('feat') || msgLower.includes('implement') || msgLower.includes('new ')) {
return 'features'
}
@@ -242,9 +253,10 @@ function categorizeCommit(message: string): 'features' | 'fixes' | 'improvements
if (
msgLower.includes('improve') ||
msgLower.includes('enhance') ||
- msgLower.includes('update') ||
msgLower.includes('upgrade') ||
- msgLower.includes('optimization')
+ msgLower.includes('optimization') ||
+ msgLower.includes('add') ||
+ msgLower.includes('update')
) {
return 'improvements'
}