Skip to content
Merged
81 changes: 79 additions & 2 deletions apps/docs/content/docs/en/sdks/typescript.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -593,14 +593,91 @@ async function executeClientSideWorkflow() {
});

console.log('Workflow result:', result);

// Update UI with result
document.getElementById('result')!.textContent =
document.getElementById('result')!.textContent =
JSON.stringify(result.output, null, 2);
} catch (error) {
console.error('Error:', error);
}
}
```

### File Upload

File objects are automatically detected and converted to base64 format. Include them in your input under the field name matching your workflow's API trigger input format.

The SDK converts File objects to this format:
```typescript
{
type: 'file',
data: 'data:mime/type;base64,base64data',
name: 'filename',
mime: 'mime/type'
}
```

Alternatively, you can manually provide files using the URL format:
```typescript
{
type: 'url',
data: 'https://example.com/file.pdf',
name: 'file.pdf',
mime: 'application/pdf'
}
```

<Tabs items={['Browser', 'Node.js']}>
<Tab value="Browser">
```typescript
import { SimStudioClient } from 'simstudio-ts-sdk';

const client = new SimStudioClient({
apiKey: process.env.NEXT_PUBLIC_SIM_API_KEY!
});

// From file input
async function handleFileUpload(event: Event) {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files || []);

// Include files under the field name from your API trigger's input format
const result = await client.executeWorkflow('workflow-id', {
input: {
documents: files, // Must match your workflow's "files" field name
instructions: 'Analyze these documents'
}
});

console.log('Result:', result);
}
```
</Tab>
<Tab value="Node.js">
```typescript
import { SimStudioClient } from 'simstudio-ts-sdk';
import fs from 'fs';

const client = new SimStudioClient({
apiKey: process.env.SIM_API_KEY!
});

// Read file and create File object
const fileBuffer = fs.readFileSync('./document.pdf');
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], // Must match your workflow's "files" field name
query: 'Summarize this document'
}
});
```
</Tab>
</Tabs>

// Attach to button click
document.getElementById('executeBtn')?.addEventListener('click', executeClientSideWorkflow);
Expand Down
59 changes: 57 additions & 2 deletions apps/docs/content/docs/en/triggers/api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,17 @@ The API trigger exposes your workflow as a secure HTTP endpoint. Send JSON data
/>
</div>

Add an **Input Format** field for each parameter. Runtime output keys mirror the schema and are also available under `<api.input>`.
Add an **Input Format** field for each parameter. Supported types:

Manual runs in the editor use the `value` column so you can test without sending a request. During execution the resolver populates both `<api.userId>` and `<api.input.userId>`.
- **string** - Text values
- **number** - Numeric values
- **boolean** - True/false values
- **json** - JSON objects
- **files** - File uploads (access via `<api.fieldName[0].url>`, `<api.fieldName[0].name>`, etc.)

Runtime output keys mirror the schema and are available under `<api.input>`.

Manual runs in the editor use the `value` column so you can test without sending a request. During execution the resolver populates both `<api.fieldName>` and `<api.input.fieldName>`.

## Request Example

Expand Down Expand Up @@ -123,6 +131,53 @@ data: {"blockId":"agent1-uuid","chunk":" complete"}
| `<api.field>` | Field defined in the Input Format |
| `<api.input>` | Entire structured request body |

### File Upload Format

The API accepts files in two formats:

**1. Base64-encoded files** (recommended for SDKs):
```json
{
"documents": [{
"type": "file",
"data": "data:application/pdf;base64,JVBERi0xLjQK...",
"name": "document.pdf",
"mime": "application/pdf"
}]
}
```
- Maximum file size: 20MB per file
- Files are uploaded to cloud storage and converted to UserFile objects with all properties

**2. Direct URL references**:
```json
{
"documents": [{
"type": "url",
"data": "https://example.com/document.pdf",
"name": "document.pdf",
"mime": "application/pdf"
}]
}
```
- File is not uploaded, URL is passed through directly
- Useful for referencing existing files

### File Properties

For files, access all properties:

| Property | Description | Type |
|----------|-------------|------|
| `<api.fieldName[0].url>` | Signed download URL | string |
| `<api.fieldName[0].name>` | Original filename | string |
| `<api.fieldName[0].size>` | File size in bytes | number |
| `<api.fieldName[0].type>` | MIME type | string |
| `<api.fieldName[0].uploadedAt>` | Upload timestamp (ISO 8601) | string |
| `<api.fieldName[0].expiresAt>` | URL expiry timestamp (ISO 8601) | string |

For URL-referenced files, the same properties are available except `uploadedAt` and `expiresAt` since the file is not uploaded to our storage.

If no Input Format is defined, the executor exposes the raw JSON at `<api.input>` only.

<Callout type="warning">
Expand Down
25 changes: 18 additions & 7 deletions apps/docs/content/docs/en/triggers/chat.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,24 @@ The Chat trigger creates a conversational interface for your workflow. Deploy yo

The trigger writes three fields that downstream blocks can reference:

| Reference | Description |
|-----------|-------------|
| `<chat.input>` | Latest user message |
| `<chat.conversationId>` | Conversation thread ID |
| `<chat.files>` | Optional uploaded files |

Files include `name`, `mimeType`, and a signed download `url`.
| Reference | Description | Type |
|-----------|-------------|------|
| `<chat.input>` | Latest user message | string |
| `<chat.conversationId>` | Conversation thread ID | string |
| `<chat.files>` | Optional uploaded files | files array |

### File Properties

Access individual file properties using array indexing:

| Property | Description | Type |
|----------|-------------|------|
| `<chat.files[0].url>` | Signed download URL | string |
| `<chat.files[0].name>` | Original filename | string |
| `<chat.files[0].size>` | File size in bytes | number |
| `<chat.files[0].type>` | MIME type | string |
| `<chat.files[0].uploadedAt>` | Upload timestamp (ISO 8601) | string |
| `<chat.files[0].expiresAt>` | URL expiry timestamp (ISO 8601) | string |

## Usage Notes

Expand Down
20 changes: 14 additions & 6 deletions apps/sim/app/api/__test-utils__/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1116,12 +1116,20 @@ export function createMockDatabase(options: MockDatabaseOptions = {}) {

const createUpdateChain = () => ({
set: vi.fn().mockImplementation(() => ({
where: vi.fn().mockImplementation(() => {
if (updateOptions.throwError) {
return Promise.reject(createDbError('update', updateOptions.errorMessage))
}
return Promise.resolve(updateOptions.results)
}),
where: vi.fn().mockImplementation(() => ({
returning: vi.fn().mockImplementation(() => {
if (updateOptions.throwError) {
return Promise.reject(createDbError('update', updateOptions.errorMessage))
}
return Promise.resolve(updateOptions.results)
}),
then: vi.fn().mockImplementation((resolve) => {
if (updateOptions.throwError) {
return Promise.reject(createDbError('update', updateOptions.errorMessage))
}
return Promise.resolve(updateOptions.results).then(resolve)
}),
})),
})),
})

Expand Down
31 changes: 25 additions & 6 deletions apps/sim/app/api/chat/[identifier]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import { generateRequestId } from '@/lib/utils'
import {
addCorsHeaders,
processChatFiles,
setChatAuthCookie,
validateAuthToken,
validateChatAuth,
Expand Down Expand Up @@ -75,7 +76,7 @@ export async function POST(
}

// Use the already parsed body
const { input, password, email, conversationId } = parsedBody
const { input, password, email, conversationId, files } = parsedBody

// If this is an authentication request (has password or email but no input),
// set auth cookie and return success
Expand All @@ -88,8 +89,8 @@ export async function POST(
return response
}

// For chat messages, create regular response
if (!input) {
// For chat messages, create regular response (allow empty input if files are present)
if (!input && (!files || files.length === 0)) {
return addCorsHeaders(createErrorResponse('No input provided', 400), request)
}

Expand All @@ -108,7 +109,6 @@ export async function POST(
}

try {
// Transform outputConfigs to selectedOutputs format (blockId_attribute format)
const selectedOutputs: string[] = []
if (deployment.outputConfigs && Array.isArray(deployment.outputConfigs)) {
for (const config of deployment.outputConfigs) {
Expand All @@ -123,11 +123,30 @@ export async function POST(
const { SSE_HEADERS } = await import('@/lib/utils')
const { createFilteredResult } = await import('@/app/api/workflows/[id]/execute/route')

const workflowInput: any = { input, conversationId }
if (files && Array.isArray(files) && files.length > 0) {
logger.debug(`[${requestId}] Processing ${files.length} attached files`)

const executionId = crypto.randomUUID()
const executionContext = {
workspaceId: deployment.userId,
workflowId: deployment.workflowId,
executionId,
}

const uploadedFiles = await processChatFiles(files, executionContext, requestId)

if (uploadedFiles.length > 0) {
workflowInput.files = uploadedFiles
logger.info(`[${requestId}] Successfully processed ${uploadedFiles.length} files`)
}
}

const stream = await createStreamingResponse({
requestId,
workflow: { id: deployment.workflowId, userId: deployment.userId, isDeployed: true },
input: { input, conversationId }, // Format for chat_trigger
executingUserId: deployment.userId, // Use workflow owner's ID for chat deployments
input: workflowInput,
executingUserId: deployment.userId,
streamConfig: {
selectedOutputs,
isSecureMode: true,
Expand Down
60 changes: 60 additions & 0 deletions apps/sim/app/api/chat/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { isDev } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
import { hasAdminPermission } from '@/lib/permissions/utils'
import { decryptSecret } from '@/lib/utils'
import { uploadExecutionFile } from '@/lib/workflows/execution-file-storage'
import type { UserFile } from '@/executor/types'

const logger = createLogger('ChatAuthUtils')

Expand Down Expand Up @@ -263,3 +265,61 @@ export async function validateChatAuth(
// Unknown auth type
return { authorized: false, error: 'Unsupported authentication type' }
}

/**
* Process and upload chat files to execution storage
* Handles both base64 dataUrl format and direct URL pass-through
*/
export async function processChatFiles(
files: Array<{ dataUrl?: string; url?: string; name: string; type: string }>,
executionContext: { workspaceId: string; workflowId: string; executionId: string },
requestId: string
): Promise<UserFile[]> {
const uploadedFiles: UserFile[] = []

for (const file of files) {
try {
if (file.dataUrl) {
const dataUrlPrefix = 'data:'
const base64Prefix = ';base64,'

if (!file.dataUrl.startsWith(dataUrlPrefix)) {
logger.warn(`[${requestId}] Invalid dataUrl format for file: ${file.name}`)
continue
}

const base64Index = file.dataUrl.indexOf(base64Prefix)
if (base64Index === -1) {
logger.warn(
`[${requestId}] Invalid dataUrl format (no base64 marker) for file: ${file.name}`
)
continue
}

const mimeType = file.dataUrl.substring(dataUrlPrefix.length, base64Index)
const base64Data = file.dataUrl.substring(base64Index + base64Prefix.length)
const buffer = Buffer.from(base64Data, 'base64')

logger.debug(`[${requestId}] Uploading file to S3: ${file.name} (${buffer.length} bytes)`)

const userFile = await uploadExecutionFile(
executionContext,
buffer,
file.name,
mimeType || file.type
)

uploadedFiles.push(userFile)
logger.debug(`[${requestId}] Successfully uploaded ${file.name} with URL: ${userFile.url}`)
} else if (file.url) {
uploadedFiles.push(file as UserFile)
logger.debug(`[${requestId}] Using existing URL for file: ${file.name}`)
}
} catch (error) {
logger.error(`[${requestId}] Failed to process file ${file.name}:`, error)
throw new Error(`Failed to upload file: ${file.name}`)
}
}

return uploadedFiles
}
Loading