Compare commits

...

36 Commits

Author SHA1 Message Date
Waleed
9fcd02fd3b v0.6.5: email validation, integrations page, mothership and custom tool fixes 2026-03-19 16:08:30 -07:00
Waleed
6326353f5c feat(okta): add complete Okta identity management integration (#3685)
* feat(okta): add complete Okta identity management integration

Add 18 Okta Management API tools covering user lifecycle (list, get,
create, update, activate, deactivate, suspend, unsuspend, reset password,
delete) and group management (list, get, create, update, delete, add/remove
members, list members). Includes block with conditional UI, icon, registry
entries, and generated docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs(okta): add manual description section to generated docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(okta): address PR review — SSRF prevention, safe response parsing, consistent sendEmail

- Add validateOktaDomain() to prevent SSRF via user-supplied domain param
- Fix 9 tools to check response.ok before calling response.json()
- Make sendEmail query param explicit in deactivate_user and delete_user

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(okta): only forward boolean switches when explicitly true

Switch subBlocks default to OFF (false), which was being forwarded to
tools and overriding their default-true behavior for sendEmail and
activate params. Now only forward these when explicitly toggled ON.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(okta): use nullish coalescing for boolean switch defaults

Block now forwards sendEmail/activate values as-is (including false).
Tools use ?? operator so: explicit true/false from switches are respected,
undefined (programmatic calls) still defaults to true.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(okta): prevent silent data loss in update operations

- update_group: always include description in PUT body (defaults to '')
  since PUT replaces the full profile object
- update_user: use !== undefined checks so empty strings can clear fields
  via Okta's POST partial update
- block: allow empty strings through passthrough loop and use !== undefined
  for groupDescription mapping

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(okta): move validateOktaDomain to centralized input-validation

- Moved validateOktaDomain from tools/okta/types.ts to
  lib/core/security/input-validation.ts alongside other validation utils
- Added .trim() to handle copy-paste whitespace in domain input
- Updated all 18 tool files to import from the new location

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 16:01:27 -07:00
Waleed
d3daab743f feat(microsoft-ad): add Azure AD (Entra ID) integration (#3686)
* feat(microsoft-ad): add Azure AD (Entra ID) integration

Add complete Azure AD integration with 13 tools for managing users
and groups via Microsoft Graph API v1.0. Includes OAuth config with
PKCE, block definition with conditional subBlocks, and generated docs.

Tools: list/get/create/update/delete users, list/get/create/update/delete
groups, list/add/remove group members.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(microsoft-ad): add $search/$filter guard, $count=true, and memberId validation

- Prevent using $search and $filter together (Graph API rejects this)
- Add $count=true when $search is used (required with ConsistencyLevel: eventual)
- Validate and trim memberId in add_group_member body before use

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(microsoft-ad): fix docsLink underscore and accountEnabled update safety

- Change docsLink from microsoft-ad to microsoft_ad to match docs routing
- Split accountEnabled dropdown into separate create/update subBlocks
- Update operation shows "No Change" default (empty string) to prevent
  silently re-enabling disabled accounts when updating other fields
- Create operation keeps "Yes" default as before

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(microsoft-ad): prevent visibility from always being sent on group update

Split visibility dropdown into separate create/update subBlocks with
"No Change" default for update_group, preventing silent overwrite of
group visibility when updating other fields like description.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(microsoft-ad): prevent empty values leaking into PATCH requests

- Use operation-aware checks for accountEnabled and visibility in block
  params to prevent create defaults bleeding into update operations
- Change tool body guards from `!== undefined` to truthy checks so
  empty-string inputs from unfilled subBlocks are omitted from PATCH

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 15:49:17 -07:00
Waleed
0d22cc3186 feat(infisical): add Infisical secrets management integration (#3684)
* feat(infisical): add Infisical secrets management integration

* fix(infisical): rename tool files to underscores, add configurable baseUrl, fix error type casts

* fix(infisical): make get_secret fallback consistent with other tools

* fix(infisical): add type casts to fix TypeScript build error in tag/metadata mapping

* fix(infisical): guard empty secretValue, validate version number, move DELETE params to query string

* fix(infisical): use falsy check for secretComment to prevent clearing existing comments
2026-03-19 15:30:51 -07:00
Waleed
413c45d863 improvement(platform): landing page cleanup, MX cache fixes, and auth util extraction (#3683)
* fix(enterprise): remove dead variables resourceLabel, CHECK_PATH, allFeatures, RESOURCE_TYPE_LABEL

* fix: cap MX cache size, deduplicate validateCallbackUrl, add slug duplicate guard

* revert: remove slug duplicate guard

* refactor: extract validateCallbackUrl to shared util, evict stale MX cache entries on lookup

* refactor: move validateCallbackUrl into input-validation.ts

* fix: guard validateCallbackUrl against server-side window, skip eviction on cache update

* fix(auth): remove redundant validateCallbackUrl re-check on already-safe callbackUrl

* chore(auth): add comment explaining why safeCallbackUrl skip re-validation

* chore: remove redundant inline comments
2026-03-19 14:02:35 -07:00
Vikhyath Mondreti
30b7192e75 improvement(vfs): update custom glob impl to use micromatch, fix vfs filename regex (#3680)
* improvement(vfs): update custom glob impl to use micromatch, fix vfs filename regex

* add tests

* file caps

* address comments

* fix open resource

* consolidate files
2026-03-19 13:54:55 -07:00
Waleed
17bdc80eb9 improvement(platform): added more email validation utils, added integrations page, improved enterprise section, update docs generation script (#3667)
* improvement(platform): added more email validation utils, added integrations page, improved enterprise section, update docs generation script

* remove unused route

* restore hardcoded ff

* updated

* chore: install soap package types for workday integration

* fix(integrations): strip version suffix for template matching, add MX DNS cache

* change ff

* remove extraneous comments

* fix(email): cache timeout results in MX check to prevent repeated 5s waits
2026-03-19 13:02:03 -07:00
Waleed
c3c22e4674 improvement(react): replace unnecessary useEffect patterns with better React primitives (#3675)
* improvement(react): replace unnecessary useEffect patterns with better React primitives

* fix(react): revert unsafe render-time side effects to useEffect

* fix(react): restore useEffect for modals, scroll, and env sync

- Modals (create-workspace, rename-document, edit-knowledge-base): restore
  useEffect watching `open` prop for form reset on programmatic open, since
  Radix onOpenChange doesn't fire for parent-driven prop changes
- Popover: add useEffect watching `open` for programmatic close reset
- Chat scroll: restore useEffect watching `isStreamingResponse` so the 1s
  suppression timer starts when streaming begins, not before the fetch
- Credentials manager: revert render-time pattern to useEffect for initial
  sync from cached React Query data (useRef captures initial value, making
  the !== check always false on mount)

* fix(react): restore useEffect for help/invite modals, combobox index reset

- Help modal: restore useEffect watching `open` for form reset on
  programmatic open (same Radix onOpenChange pattern as other modals)
- Invite modal: restore useEffect watching `open` to clear error on
  programmatic open
- Combobox: restore useEffect to reset highlightedIndex when filtered
  options shrink (prevents stale index from reappearing when options grow)
- Remove no-op handleOpenChange wrappers in rename-document and
  edit-knowledge-base modals (now pure pass-throughs after useEffect fix)

* fix(context-menu): use requestAnimationFrame for ColorGrid focus, remove no-op wrapper in create-workspace-modal

- ColorGrid: replaced setTimeout with requestAnimationFrame for initial
  focus to wait for submenu paint completion
- create-workspace-modal: removed handleOpenChange pass-through wrapper,
  use onOpenChange directly

* fix(files): restore filesRef pattern to prevent preview mode reset on refetch

The useEffect that sets previewMode should only run when selectedFileId
changes, not when files array reference changes from React Query refetch.
Restores the filesRef pattern to read latest files without triggering
the effect — prevents overriding user's manual mode selection.

* fix(add-documents-modal, combobox): restore useEffect for modal reset, fix combobox dep array

- add-documents-modal: handleOpenChange(true) is dead code in Radix
  controlled mode — restored useEffect watching open for reset-on-open
- combobox: depend on filteredOptions array (not .length) so highlight
  resets when items change even with same count
2026-03-19 12:57:10 -07:00
Waleed
ce3d2d5e95 fix(oauth): fall back to configured scopes when DB scope is empty (#3678)
Providers like Box don't return a scope field in their token response,
leaving the account.scope column empty. The credentials API now falls
back to the provider's configured scopes when the stored scope is
empty, preventing false "Additional permissions required" banners.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 12:14:16 -07:00
Waleed
507954c2d5 fix(home): stop sidebar collapsing when artifact opens (#3677) 2026-03-19 11:48:51 -07:00
Theodore Li
25789855af fix(tool): Fix custom tools spreading out string output (#3676)
* fix(tool): Fix issue with custom tools spreading out string output

* Fix lint

* Avoid any transformation on custom tool outputs

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-19 14:44:38 -04:00
Vikhyath Mondreti
27a41d4e33 fix(open-resource): open resource tool to open existing files (#3670)
* fix(open-resource): open resource tool to open existing files

* fix loading state

* address comment

* remove title
2026-03-19 10:39:43 -07:00
Waleed
ff7b5b528c v0.6.4: subflows, docusign, ashby new tools, box, workday, billing bug fixes 2026-03-18 23:12:36 -07:00
Waleed
cef321bda2 feat(box): add Box and Box Sign integrations (#3660)
* feat(box): add Box and Box Sign integrations

Add complete Box integration with file management (upload, download, get info, list folders, create/delete folders, copy, search, update metadata) and Box Sign e-signature support (create/get/list/cancel/resend sign requests). Includes OAuth provider setup, internal upload API route following the Dropbox pattern, block configurations, icon, and generated docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(box): address PR review comments

- Fix docsLink for Box Sign: use underscore (box_sign) to match docs URL
- Move normalizeFileInput from tool() to params() in Box block config to match Dropbox pattern
- Throw error on invalid additionalSigners JSON instead of silently dropping signers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(box): remove unsupported reason param from cancel sign request

The Box Sign cancel endpoint (POST /sign_requests/{id}/cancel) does not
accept a request body per the API specification. Remove the misleading
reason parameter from the tool, types, block config, and docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(box): use canonical param ID for file normalization in params()

The params function must reference canonical IDs (params.file), not raw
subBlock IDs (uploadFile/fileRef) which are deleted after canonical
transformation. Matches the Dropbox block pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(box): use generic output descriptions for shared file properties

Rename "Uploaded file ID/name" to "File ID/name" in
UPLOAD_FILE_OUTPUT_PROPERTIES since the constant is shared by both
upload and copy operations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(box): rename items output to entries for list_folder_items

Rename the output field from "items" to "entries" to match Box API
naming and avoid collision with JSON schema "items" keyword that
prevented the docs generator from rendering the nested array structure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(box): filter empty file IDs from sourceFileIds input

Add .filter(Boolean) after splitting sourceFileIds to prevent empty
strings from trailing/double commas being sent as invalid file IDs
to the Box Sign API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(box): merge Box Sign into single Box block

Combine Box and Box Sign into one unified block with all 15 operations
accessible via a single dropdown, removing the separate box_sign block.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(box): filter empty strings from tags array in update_file

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(docs): apply lint formatting to icon-mapping and meta.json

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(box): format chained method calls per linter rules

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(box,docusign): set block bgColor to white and regenerate docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(docs): apply lint formatting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(box): populate OAuth scopes for Box since token response omits them

Box's OAuth2 token endpoint does not return a scope field in the
response, so Better Auth stores nothing in the DB. This causes the
credential selector to always show "Additional permissions required".
Fix by populating the scope from the requested scopes in the
account.create.before hook.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(box): add sign_requests.readwrite scope for Box Sign operations

Box Sign API requires the sign_requests.readwrite scope in addition
to root_readwrite. Without it, sign requests fail with "The request
requires higher privileges than provided by the access token."

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* update docs

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 23:06:08 -07:00
Vikhyath Mondreti
1809b3801b improvement(billing): immediately charge for billing upgrades (#3664)
* improvement(billing): immediately charge for billing upgrades

* block on payment failures even for upgrades

* address bugbot comments
2026-03-18 22:47:31 -07:00
Vikhyath Mondreti
bc111a6d5c feat(workday): block + tools (#3663)
* checkpoint workday block

* add icon svg

* fix workday to use soap api

* fix SOAP API

* address comments

* fix

* more type fixes

* address more comments

* fix files

* fix file editor useEffect

* fix build issue

* fix typing

* fix test
2026-03-18 22:26:10 -07:00
Waleed
12908c14be feat(ashby): add 15 new tools and fix existing tool accuracy (#3662)
* feat(ashby): add 15 new tools and fix existing tool accuracy

* fix(ashby): fix response field mappings for changeStage and createNote

* fix(ashby): fix websiteUrl field name in candidate.update request

* fix(ashby): revert body field names to candidateId and jobId for info endpoints

* fix(ashby): add subblock ID migrations for removed emailType and phoneType

* fix(ashby): map removed emailType/phoneType to dummy keys to avoid data corruption
2026-03-18 22:12:16 -07:00
Waleed
638063cac1 feat(docusign): add docusign integration (#3661)
* feat(docusign): add DocuSign e-signature integration

* fix(docusign): add base_uri null check and move file normalization to params

* fix(docusign): use canonical param documentFile instead of raw subBlock IDs

* fix(docusign): validate document file is present before sending envelope

* fix(docusign): rename tool files from kebab-case to snake_case for docs generation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 22:07:51 -07:00
Vikhyath Mondreti
5f7a980c5f fix(schedules): deployment bug (#3666)
* fix(schedules): deployment bug

* fix
2026-03-18 21:39:13 -07:00
Vikhyath Mondreti
a2c08e19a8 fix(subflows): subflow-child selection issues, subflow error logs (#3656)
* fix(subflows): subflow-child selection issues, subflow error logs

* address comments

* make selection context more reliable

* fix more multiselect issues

* fix shift selection ordering to work correctly

* fix more comments

* address more comments

* reuse helper
2026-03-18 19:08:14 -07:00
Waleed
30f2d1a0fc v0.6.3: hubspot integration, kb block improvements 2026-03-18 11:19:55 -07:00
Theodore Li
5332614a19 fix(mothership): mothership-ran workflows show workflow validation errors (#3634)
* fix(mothership): mothership-ran workflows show workflow validation errors

* Distinguish errors from 5xxs

* Unify workflow event handling

* Fix run from block

* Fix lint

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-18 13:55:59 -04:00
Waleed
ff5d90e0c0 fix(knowledge): infer MIME type from file extension in create/upsert tools (#3651)
* fix(knowledge): infer MIME type from file extension in create/upsert tools

Both create_document and upsert_document forced .txt extension and
text/plain MIME type regardless of the document name. Now the tools
infer the correct MIME type from the file extension (html, md, csv,
json, yaml, xml) and only default to .txt when no extension is given.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(knowledge): reuse existing getMimeTypeFromExtension from uploads

Replace duplicate EXTENSION_MIME_MAP and getMimeTypeFromExtension with
the existing, more comprehensive version from lib/uploads/utils/file-utils.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(knowledge): fix btoa stack overflow and duplicate encoding in create_document

Same fixes as upsert_document: use loop-based String.fromCharCode
instead of spread, consolidate duplicate TextEncoder calls, and
check byte length instead of character length for 1MB limit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(knowledge): allowlist text-compatible MIME types in inferDocumentFileInfo

Use an explicit allowlist instead of only checking for octet-stream,
preventing binary MIME types (image/jpeg, audio/mpeg, etc.) from
leaking through when a user names a document with a binary extension.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(knowledge): remove pdf/rtf from allowlist, normalize unrecognized extensions

- Remove application/pdf and application/rtf from TEXT_COMPATIBLE_MIME_TYPES
  since these tools pass plain text content, not binary
- Normalize unrecognized extensions (e.g. report.v2) to .txt instead of
  preserving the original extension with text/plain MIME type

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(knowledge): handle dotfile names to avoid empty base in filename

Dotfiles like .env would produce an empty base, resulting in '.txt'.
Now falls back to the original name so .env becomes .env.txt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 10:49:18 -07:00
Waleed
8b245693e2 fix(hubspot): add missing tickets and oauth scopes to OAuth config (#3653) 2026-03-18 10:34:49 -07:00
Waleed
4bd0731871 v0.6.2: mothership stability, chat iframe embedding, KB upserts, new blog post 2026-03-18 03:29:39 -07:00
Waleed
60bb9422ca feat(blog): add v0.6 blog post and email broadcast (#3636)
* chore(blog): add v0.6 blog post and email broadcast scaffolding

* mothership blog

* turned on mothership blog

* small change

---------

Co-authored-by: Emir Karabeg <emirkarabeg@berkeley.edu>
2026-03-18 03:25:59 -07:00
Waleed
8a4c161ec4 feat(home): resizable chat/resource panel divider (#3648)
* feat(home): resizable chat/resource panel divider

* fix(home): address PR review comments

- Remove aria-hidden from resize handle outer div so separator role is visible to AT
- Add viewport-resize re-clamping in useMothershipResize to prevent panel exceeding max % after browser window narrows
- Change default MothershipView width from 60% to 50%

* refactor(home): eradicate useEffect anti-patterns per you-might-not-need-an-effect

- use-chat: remove messageQueue→ref sync Effect; inline assignment like other refs
- use-chat: replace activeResourceId selection Effect with useMemo (derived value, avoids
  extra re-render cycle; activeResourceIdRef now tracks effective value for API payloads)
- use-chat: replace 3x useLayoutEffect ref-sync (processSSEStream, finalize, sendMessage)
  with direct render-phase assignment — consistent with existing resourcesRef pattern
- user-input: fold onEditValueConsumed callback into existing render-phase guard; remove Effect
- home: move isResourceAnimatingIn 400ms timer into expandResource/handleResourceEvent event
  handlers where setIsResourceAnimatingIn(true) is called; remove reactive Effect watcher

* fix(home): revert default width to 60%, reduce max resize to 63%

* improvement(react): replace useEffect anti-patterns with better React primitives

* improvement(react): replace useEffect anti-patterns with better React primitives

* improvement(home): use pointer events for resize handle (touch + mouse support)

* fix(autosave): store idle-reset timer ref to prevent status corruption on rapid saves

* fix(home): move onEditValueConsumed call out of render phase into useEffect

* fix(home): add pointercancel handler; fix(settings): sync name on profile refetch

* fix(home): restore cleanupRef assignment dropped during AbortController refactor
2026-03-18 02:57:44 -07:00
Waleed
b84f30e9e7 fix(db): reduce connection pool sizes to prevent exhaustion (#3649) 2026-03-18 02:20:55 -07:00
Waleed
28de28899a improvement(landing): added enterprise section (#3637)
* improvement(landing): added enterprise section

* make components interactive

* added more things to pricing sheet

* remove debug log

* fix(landing): remove dead DotGrid component and fix enterprise CTA to use Link

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 20:38:16 -07:00
Siddharth Ganesan
168cd585cb feat(mothership): request ids (#3645)
* Include rid

* Persist rid

* fix ui

* address comments

* update types

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-03-17 18:24:27 -07:00
Waleed
5f89c7140c feat(knowledge): add upsert document operation (#3644)
* feat(knowledge): add upsert document operation to Knowledge block

Add a "Create or Update" (upsert) document capability that finds an
existing document by ID or filename, deletes it if found, then creates
a new document and queues re-processing. Includes new tool, API route,
block wiring, and typed interfaces.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(knowledge): address review comments on upsert document

- Reorder create-then-delete to prevent data loss if creation fails
- Move Zod validation before workflow authorization for validated input
- Fix btoa stack overflow for large content using loop-based encoding

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(knowledge): guard against empty createDocumentRecords result

Add safety check before accessing firstDocument to prevent TypeError
and data loss if createDocumentRecords unexpectedly returns empty.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(knowledge): prevent documentId fallthrough and use byte-count limit

- Use if/else so filename lookup only runs when no documentId is provided,
  preventing stale IDs from silently replacing unrelated documents
- Check utf8 byte length instead of character count for 1MB size limit,
  correctly handling multi-byte characters (CJK, emoji)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(knowledge): rollback on delete failure, deduplicate sub-block IDs

- Add compensating rollback: if deleteDocument throws after create
  succeeds, clean up the new record to prevent orphaned pending docs
- Merge duplicate name/content sub-blocks into single entries with
  array conditions, matching the documentTags pattern

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* lint

* lint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 18:14:29 -07:00
Waleed
2bc11a70ba waleedlatif1/hangzhou v2 (#3647)
* feat(admin): add user search by email and ID, remove table border

- Replace Load Users button with a live search input; query fires on any input
- Email search uses listUsers with contains operator
- User ID search (UUID format) uses admin.getUser directly for exact lookup
- Remove outer border on user table that rendered white in dark mode
- Reset pagination to page 0 on new search

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(admin): replace live search with explicit search button

- Split searchInput (controlled input) from searchQuery (committed value)
  so the hook only fires on Search click or Enter, not every keystroke
- Gate table render on searchQuery.length > 0 to prevent stale results
  showing after input is cleared

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 18:00:55 -07:00
PlaneInABottle
67478bbc80 fix(logs): add durable execution diagnostics foundation (#3564)
* fix(logs): persist execution diagnostics markers

Store last-started and last-completed block markers with finalization metadata so later read surfaces can explain how a run ended without reconstructing executor state.

* fix(executor): preserve durable diagnostics ordering

Await only the persistence needed to keep diagnostics durable before terminal completion while keeping callback failures from changing execution behavior.

* fix(logs): preserve fallback diagnostics semantics

Keep successful fallback output and accumulated cost intact while tightening progress-write draining and deduplicating trace span counting for diagnostics helpers.

* fix(api): restore async execute route test mock

Add the missing AuthType export to the hybrid auth mock so the async execution route test exercises the 202 queueing path instead of crashing with a 500 in CI.

* fix(executor): align async block error handling

* fix(logs): tighten marker ordering scope

Allow same-millisecond marker writes to replace prior markers and drop the unused diagnostics read helper so this PR stays focused on persistence rather than unread foundation code.

* fix(logs): remove unused finalization type guard

Drop the unused  helper so this PR only ships the persistence-side status types it actually uses.

* fix(executor): await subflow diagnostics callbacks

Ensure empty-subflow and subflow-error lifecycle callbacks participate in progress-write draining before terminal finalization while still swallowing callback failures.

---------

Co-authored-by: test <test@example.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-03-17 17:24:40 -07:00
Waleed
c9f082da1a feat(csp): allow chat UI to be embedded in iframes (#3643)
* feat(csp): allow chat UI to be embedded in iframes

Mirror the existing form embed CSP pattern for chat pages: add
getChatEmbedCSPPolicy() with frame-ancestors *, configure /chat/:path*
headers in next.config.ts without X-Frame-Options, and early-return in
proxy.ts so chat routes skip the strict runtime CSP.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(csp): extract shared getEmbedCSPPolicy helper

Deduplicate getChatEmbedCSPPolicy and getFormEmbedCSPPolicy into a
shared private helper to prevent future divergence.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 17:12:34 -07:00
Waleed
75a3e2c3a8 fix(workspace): prevent stale placeholder data from corrupting workflow registry on switch 2026-03-17 16:20:06 -07:00
Siddharth Ganesan
cdd0f75cd5 fix(mothership): fix mothership file uploads (#3640)
* Fix files

* Fix

* Fix
2026-03-17 16:19:47 -07:00
329 changed files with 36962 additions and 2082 deletions

View File

@@ -124,6 +124,34 @@ export function NoteIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function WorkdayIcon(props: SVGProps<SVGSVGElement>) {
const id = useId()
const clipId = `workday_clip_${id}`
return (
<svg {...props} viewBox='0 0 64 64' fill='none' xmlns='http://www.w3.org/2000/svg'>
<g clipPath={`url(#${clipId})`} transform='matrix(0.53333333,0,0,0.53333333,-124.63685,-16)'>
<path
fillRule='evenodd'
clipRule='evenodd'
d='m 251.21,88.7755 h 8.224 c 1.166,0 2.178,0.7836 2.444,1.8924 l 11.057,44.6751 c 0.152,0.002 12.182,-44.6393 12.182,-44.6393 0.306,-1.1361 1.36,-1.9282 2.566,-1.9282 h 12.74 c 1.144,0 2.144,0.7515 2.435,1.8296 l 12.118,44.9289 c 0.448,-0.282 11.147,-44.8661 11.147,-44.8661 0.267,-1.1088 1.279,-1.8924 2.444,-1.8924 h 8.219 c 1.649,0 2.854,1.5192 2.437,3.0742 l -15.08,56.3173 c -0.286,1.072 -1.272,1.823 -2.406,1.833 l -12.438,-0.019 c -1.142,-0.002 -2.137,-0.744 -2.429,-1.819 -2.126,-7.805 -12.605,-47.277 -12.605,-47.277 0,0 -11.008,39.471 -13.133,47.277 -0.293,1.075 -1.288,1.817 -2.429,1.819 L 266.264,150 c -1.133,-0.01 -2.119,-0.761 -2.406,-1.833 L 248.777,91.8438 c -0.416,-1.5524 0.786,-3.0683 2.433,-3.0683 z'
fill='#005cb9'
/>
<path
fillRule='evenodd'
clipRule='evenodd'
d='m 333.324,72.2449 c 0.531,0 1.071,-0.0723 1.608,-0.2234 3.18,-0.8968 5.039,-4.2303 4.153,-7.446 -0.129,-0.4673 -0.265,-0.9327 -0.408,-1.3936 C 332.529,43.3349 314.569,30 293.987,30 c -20.557,0 -38.51,13.3133 -44.673,33.1281 -0.136,0.4355 -0.267,0.8782 -0.391,1.3232 -0.902,3.2119 0.943,6.5541 4.12,7.4645 3.173,0.9112 6.48,-0.9547 7.381,-4.1666 0.094,-0.3322 0.19,-0.6616 0.292,-0.9892 4.591,-14.7582 17.961,-24.6707 33.271,-24.6707 15.329,0 28.704,9.9284 33.281,24.7063 0.105,0.3397 0.206,0.682 0.301,1.0263 0.737,2.6726 3.139,4.423 5.755,4.423 z'
fill='#f38b00'
/>
</g>
<defs>
<clipPath id={clipId}>
<path d='M 354,30 H 234 v 120 h 120 z' fill='#ffffff' />
</clipPath>
</defs>
</svg>
)
}
export function WorkflowIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
@@ -1146,6 +1174,25 @@ export function DevinIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function DocuSignIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 1547 1549' xmlns='http://www.w3.org/2000/svg'>
<path
d='m1113.4 1114.9v395.6c0 20.8-16.7 37.6-37.5 37.6h-1038.4c-20.7 0-37.5-16.8-37.5-37.6v-1039c0-20.7 16.8-37.5 37.5-37.5h394.3v643.4c0 20.7 16.8 37.5 37.5 37.5z'
fill='#4c00ff'
/>
<path
d='m1546 557.1c0 332.4-193.9 557-432.6 557.8v-418.8c0-12-4.8-24-13.5-31.9l-217.1-217.4c-8.8-8.8-20-13.6-32-13.6h-418.2v-394.8c0-20.8 16.8-37.6 37.5-37.6h585.1c277.7-0.8 490.8 223 490.8 556.3z'
fill='#ff5252'
/>
<path
d='m1099.9 663.4c8.7 8.7 13.5 19.9 13.5 31.9v418.8h-643.3c-20.7 0-37.5-16.8-37.5-37.5v-643.4h418.2c12 0 24 4.8 32 13.6z'
fill='#000000'
/>
</svg>
)
}
export function DiscordIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
@@ -1390,7 +1437,7 @@ export function AmplitudeIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 49 49'>
<path
fill='#FFFFFF'
fill='currentColor'
d='M23.4,15.3c0.6,1.8,1.2,4.1,1.9,6.7c-2.6,0-5.3-0.1-7.8-0.1h-1.3c1.5-5.7,3.2-10.1,4.6-11.1 c0.1-0.1,0.2-0.1,0.4-0.1c0.2,0,0.3,0.1,0.5,0.3C21.9,11.5,22.5,12.7,23.4,15.3z M49,24.5C49,38,38,49,24.5,49S0,38,0,24.5 S11,0,24.5,0S49,11,49,24.5z M42.7,23.9c0-0.6-0.4-1.2-1-1.3l0,0l0,0l0,0c-0.1,0-0.1,0-0.2,0h-0.2c-4.1-0.3-8.4-0.4-12.4-0.5l0,0 C27,14.8,24.5,7.4,21.3,7.4c-3,0-5.8,4.9-8.2,14.5c-1.7,0-3.2,0-4.6-0.1c-0.1,0-0.2,0-0.2,0c-0.3,0-0.5,0-0.5,0 c-0.8,0.1-1.4,0.9-1.4,1.7c0,0.8,0.6,1.6,1.5,1.7l0,0h4.6c-0.4,1.9-0.8,3.8-1.1,5.6l-0.1,0.8l0,0c0,0.6,0.5,1.1,1.1,1.1 c0.4,0,0.8-0.2,1-0.5l0,0l2.2-7.1h10.7c0.8,3.1,1.7,6.3,2.8,9.3c0.6,1.6,2,5.4,4.4,5.4l0,0c3.6,0,5-5.8,5.9-9.6 c0.2-0.8,0.4-1.5,0.5-2.1l0.1-0.2l0,0c0-0.1,0-0.2,0-0.3c-0.1-0.2-0.2-0.3-0.4-0.4c-0.3-0.1-0.5,0.1-0.6,0.4l0,0l-0.1,0.2 c-0.3,0.8-0.6,1.6-0.8,2.3v0.1c-1.6,4.4-2.3,6.4-3.7,6.4l0,0l0,0l0,0c-1.8,0-3.5-7.3-4.1-10.1c-0.1-0.5-0.2-0.9-0.3-1.3h11.7 c0.2,0,0.4-0.1,0.6-0.1l0,0c0,0,0,0,0.1,0c0,0,0,0,0.1,0l0,0c0,0,0.1,0,0.1-0.1l0,0C42.5,24.6,42.7,24.3,42.7,23.9z'
/>
</svg>
@@ -4091,6 +4138,16 @@ export function IncidentioIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function InfisicalIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 273 182' xmlns='http://www.w3.org/2000/svg'>
<path
d='m191.6 39.4c-20.3 0-37.15 13.21-52.9 30.61-12.99-16.4-29.8-30.61-51.06-30.61-27.74 0-50.44 23.86-50.44 51.33 0 26.68 21.43 51.8 48.98 51.8 20.55 0 37.07-13.86 51.32-31.81 12.69 16.97 29.1 31.41 53.2 31.41 27.13 0 49.85-22.96 49.85-51.4 0-27.12-20.44-51.33-48.95-51.33zm-104.3 77.94c-14.56 0-25.51-12.84-25.51-26.07 0-13.7 10.95-28.29 25.51-28.29 14.93 0 25.71 11.6 37.6 27.34-11.31 15.21-22.23 27.02-37.6 27.02zm104.4 0.25c-15 0-25.28-11.13-37.97-27.37 12.69-16.4 22.01-27.24 37.59-27.24 14.97 0 24.79 13.25 24.79 27.26 0 13-10.17 27.35-24.41 27.35z'
fill='currentColor'
/>
</svg>
)
}
export function IntercomIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
@@ -4569,11 +4626,17 @@ export function ShopifyIcon(props: SVGProps<SVGSVGElement>) {
export function BoxCompanyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 41 22'>
<path
d='M39.7 19.2c.5.7.4 1.6-.2 2.1-.7.5-1.7.4-2.2-.2l-3.5-4.5-3.4 4.4c-.5.7-1.5.7-2.2.2-.7-.5-.8-1.4-.3-2.1l4-5.2-4-5.2c-.5-.7-.3-1.7.3-2.2.7-.5 1.7-.3 2.2.3l3.4 4.5L37.3 7c.5-.7 1.4-.8 2.2-.3.7.5.7 1.5.2 2.2L35.8 14l3.9 5.2zm-18.2-.6c-2.6 0-4.7-2-4.7-4.6 0-2.5 2.1-4.6 4.7-4.6s4.7 2.1 4.7 4.6c-.1 2.6-2.2 4.6-4.7 4.6zm-13.8 0c-2.6 0-4.7-2-4.7-4.6 0-2.5 2.1-4.6 4.7-4.6s4.7 2.1 4.7 4.6c0 2.6-2.1 4.6-4.7 4.6zM21.5 6.4c-2.9 0-5.5 1.6-6.8 4-1.3-2.4-3.9-4-6.9-4-1.8 0-3.4.6-4.7 1.5V1.5C3.1.7 2.4 0 1.6 0 .7 0 0 .7 0 1.5v12.6c.1 4.2 3.5 7.5 7.7 7.5 3 0 5.6-1.7 6.9-4.1 1.3 2.4 3.9 4.1 6.8 4.1 4.3 0 7.8-3.4 7.8-7.7.1-4.1-3.4-7.5-7.7-7.5z'
fill='currentColor'
/>
<svg
{...props}
xmlns='http://www.w3.org/2000/svg'
width='2500'
height='1379'
viewBox='0 0 444.893 245.414'
>
<g fill='#0075C9'>
<path d='M239.038 72.43c-33.081 0-61.806 18.6-76.322 45.904-14.516-27.305-43.24-45.902-76.32-45.902-19.443 0-37.385 6.424-51.821 17.266V16.925h-.008C34.365 7.547 26.713 0 17.286 0 7.858 0 .208 7.547.008 16.925H0v143.333h.036c.768 47.051 39.125 84.967 86.359 84.967 33.08 0 61.805-18.603 76.32-45.908 14.517 27.307 43.241 45.906 76.321 45.906 47.715 0 86.396-38.684 86.396-86.396.001-47.718-38.682-86.397-86.394-86.397zM86.395 210.648c-28.621 0-51.821-23.201-51.821-51.82 0-28.623 23.201-51.823 51.821-51.823 28.621 0 51.822 23.2 51.822 51.823 0 28.619-23.201 51.82-51.822 51.82zm152.643 0c-28.622 0-51.821-23.201-51.821-51.822 0-28.623 23.2-51.821 51.821-51.821 28.619 0 51.822 23.198 51.822 51.821-.001 28.621-23.203 51.822-51.822 51.822z' />
<path d='M441.651 218.033l-44.246-59.143 44.246-59.144-.008-.007c5.473-7.62 3.887-18.249-3.652-23.913-7.537-5.658-18.187-4.221-23.98 3.157l-.004-.002-38.188 51.047-38.188-51.047-.006.009c-5.793-7.385-16.441-8.822-23.981-3.16-7.539 5.664-9.125 16.293-3.649 23.911l-.008.005 44.245 59.144-44.245 59.143.008.005c-5.477 7.62-3.89 18.247 3.649 23.909 7.54 5.664 18.188 4.225 23.981-3.155l.006.007 38.188-51.049 38.188 51.049.004-.002c5.794 7.377 16.443 8.814 23.98 3.154 7.539-5.662 9.125-16.291 3.652-23.91l.008-.008z' />
</g>
</svg>
)
}
@@ -6043,6 +6106,19 @@ export function AgentSkillsIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function OktaIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 63 63' xmlns='http://www.w3.org/2000/svg'>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M34.6.4l-1.3 16c-.6-.1-1.2-.1-1.9-.1-.8 0-1.6.1-2.3.2l-.7-7.7c0-.2.2-.5.4-.5h1.3L29.5.5c0-.2.2-.5.4-.5h4.3c.3 0 .5.2.4.4zm-10.8.8c-.1-.2-.3-.4-.5-.3l-4 1.5c-.3.1-.4.4-.3.6l3.3 7.1-1.2.5c-.2.1-.3.3-.2.6l3.3 7c1.2-.7 2.5-1.2 3.9-1.5L23.8 1.2zM14 5.7l9.3 13.1c-1.2.8-2.2 1.7-3.1 2.7L14.5 16c-.2-.2-.2-.5 0-.6l1-.8L10 9c-.2-.2-.2-.5 0-.6l3.3-2.7c.2-.3.5-.2.7 0zM6.2 13.2c-.2-.1-.5-.1-.6.1l-2.1 3.7c-.1.2 0 .5.2.6l7.1 3.4-.7 1.1c-.1.2 0 .5.2.6l7.1 3.2c.5-1.3 1.2-2.5 2-3.6L6.2 13.2zM.9 23.3c0-.2.3-.4.5-.3l15.5 4c-.4 1.3-.6 2.7-.7 4.1l-7.8-.6c-.2 0-.4-.2-.4-.5l.2-1.3L.6 28c-.2 0-.4-.2-.4-.5l.7-4.2zM.4 33.8c-.3 0-.4.2-.4.5l.8 4.2c0 .2.3.4.5.3l7.6-2 .2 1.3c0 .2.3.4.5.3l7.5-2.1c-.4-1.3-.7-2.7-.8-4.1L.4 33.8zm2.5 11.1c-.1-.2 0-.5.2-.6l14.5-6.9c.5 1.3 1.3 2.5 2.2 3.6l-6.3 4.5c-.2.1-.5.1-.6-.1L12 44.3l-6.5 4.5c-.2.1-.5.1-.6-.1l-2-3.8zm17.5-3L9.1 53.3c-.2.2-.2.5 0 .6l3.3 2.7c.2.2.5.1.6-.1l4.6-6.4 1 .9c.2.2.5.1.6-.1l4.4-6.4c-1.2-.7-2.3-1.6-3.2-2.6zm-2.2 18.2c-.2-.1-.3-.3-.2-.6L24.6 45c1.2.6 2.6 1.1 3.9 1.4l-2 7.5c-.1.2-.3.4-.5.3l-1.2-.5-2.1 7.6c-.1.2-.3.4-.5.3l-4-1.5zm10.9-13.5l-1.3 16c0 .2.2.5.4.5H33c.2 0 .4-.2.4-.5l-.6-7.8h1.3c.2 0 .4-.2.4-.5l-.7-7.7c-.8.1-1.5.2-2.3.2-.6 0-1.3 0-1.9-.1zm16-43.2c.1-.2 0-.5-.2-.6l-4-1.5c-.2-.1-.5.1-.5.3l-2.1 7.6-1.2-.5c-.2-.1-.5.1-.5.3l-2 7.5c1.4.3 2.7.8 3.9 1.4l6.6-14.5zm8.8 6.3L42.6 21.1c-.9-1-2-1.9-3.2-2.6l4.4-6.4c.1-.2.4-.2.6-.1l1 .9 4.6-6.4c.1-.2.4-.2.6-.1l3.3 2.7c.2.2.2.5 0 .6zM59.9 18.7c.2-.1.3-.4.2-.6L58 14.4c-.1-.2-.4-.3-.6-.1l-6.5 4.5-.7-1.1c-.1-.2-.4-.3-.6-.1L43.3 22c.9 1.1 1.6 2.3 2.2 3.6l14.4-6.9zm2.3 5.8l.7 4.2c0 .2-.1.5-.4.5l-15.9 1.5c-.1-1.4-.4-2.8-.8-4.1l7.5-2.1c.2-.1.5.1.5.3l.2 1.3 7.6-2c.3-.1.5.1.6.4zM61.5 40c.2.1.5-.1.5-.3l.7-4.2c0-.2-.1-.5-.4-.5l-7.8-.7.2-1.3c0-.2-.1-.5-.4-.5l-7.8-.6c0 1.4-.3 2.8-.7 4.1L61.5 40zm-4.1 9.6c-.1.2-.4.3-.6.1l-13.2-9.1c.8-1.1 1.5-2.3 2-3.6l7.1 3.2c.2.1.3.4.2.6L52.2 42l7.1 3.4c.2.1.3.4.2.6l-2.1 3.6zm-17.7-5.4L49 57.3c.1.2.4.2.6.1l3.3-2.7c.2-.2.2-.4 0-.6l-5.5-5.6 1-.8c.2-.2.2-.4 0-.6l-5.5-5.5c1.1.8 0 1.7-1.2 2.4zm0 17.8c-.2.1-.5-.1-.5-.3l-4.2-15.4c1.4-.3 2.7-.8 3.9-1.5l3.3 7c.1.2 0 .5-.2.6l-1.2.5 3.3 7.1c.1.2 0 .5-.2.6L39.7 62z'
fill='currentColor'
/>
</svg>
)
}
export function OnePasswordIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 48 48' xmlns='http://www.w3.org/2000/svg' fill='none'>

View File

@@ -16,6 +16,8 @@ import {
AsanaIcon,
AshbyIcon,
AttioIcon,
AzureIcon,
BoxCompanyIcon,
BrainIcon,
BrandfetchIcon,
BrowserUseIcon,
@@ -32,6 +34,7 @@ import {
DevinIcon,
DiscordIcon,
DocumentIcon,
DocuSignIcon,
DropboxIcon,
DsPyIcon,
DubIcon,
@@ -79,6 +82,7 @@ import {
HunterIOIcon,
ImageIcon,
IncidentioIcon,
InfisicalIcon,
IntercomIcon,
JinaAIIcon,
JiraIcon,
@@ -107,6 +111,7 @@ import {
Neo4jIcon,
NotionIcon,
ObsidianIcon,
OktaIcon,
OnePasswordIcon,
OpenAIIcon,
OutlookIcon,
@@ -162,6 +167,7 @@ import {
WhatsAppIcon,
WikipediaIcon,
WordpressIcon,
WorkdayIcon,
xIcon,
YouTubeIcon,
ZendeskIcon,
@@ -184,6 +190,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
asana: AsanaIcon,
ashby: AshbyIcon,
attio: AttioIcon,
box: BoxCompanyIcon,
brandfetch: BrandfetchIcon,
browser_use: BrowserUseIcon,
calcom: CalComIcon,
@@ -198,6 +205,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
datadog: DatadogIcon,
devin: DevinIcon,
discord: DiscordIcon,
docusign: DocuSignIcon,
dropbox: DropboxIcon,
dspy: DsPyIcon,
dub: DubIcon,
@@ -246,6 +254,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
image_generator: ImageIcon,
imap: MailServerIcon,
incidentio: IncidentioIcon,
infisical: InfisicalIcon,
intercom_v2: IntercomIcon,
jina: JinaAIIcon,
jira: JiraIcon,
@@ -263,6 +272,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
mailgun: MailgunIcon,
mem0: Mem0Icon,
memory: BrainIcon,
microsoft_ad: AzureIcon,
microsoft_dataverse: MicrosoftDataverseIcon,
microsoft_excel_v2: MicrosoftExcelIcon,
microsoft_planner: MicrosoftPlannerIcon,
@@ -273,6 +283,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
neo4j: Neo4jIcon,
notion_v2: NotionIcon,
obsidian: ObsidianIcon,
okta: OktaIcon,
onedrive: MicrosoftOneDriveIcon,
onepassword: OnePasswordIcon,
openai: OpenAIIcon,
@@ -331,6 +342,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
whatsapp: WhatsAppIcon,
wikipedia: WikipediaIcon,
wordpress: WordpressIcon,
workday: WorkdayIcon,
x: xIcon,
youtube: YouTubeIcon,
zendesk: ZendeskIcon,

View File

@@ -30,12 +30,50 @@ In Sim, the Ashby integration enables your agents to programmatically manage you
## Usage Instructions
Integrate Ashby into the workflow. Can list, search, create, and update candidates, list and get job details, create notes, list notes, list and get applications, create applications, and list offers.
Integrate Ashby into the workflow. Manage candidates (list, get, create, update, search, tag), applications (list, get, create, change stage), jobs (list, get), job postings (list, get), offers (list, get), notes (list, create), interviews (list), and reference data (sources, tags, archive reasons, custom fields, departments, locations, openings, users).
## Tools
### `ashby_add_candidate_tag`
Adds a tag to a candidate in Ashby.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Ashby API Key |
| `candidateId` | string | Yes | The UUID of the candidate to add the tag to |
| `tagId` | string | Yes | The UUID of the tag to add |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the tag was successfully added |
### `ashby_change_application_stage`
Moves an application to a different interview stage. Requires an archive reason when moving to an Archived stage.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Ashby API Key |
| `applicationId` | string | Yes | The UUID of the application to update the stage of |
| `interviewStageId` | string | Yes | The UUID of the interview stage to move the application to |
| `archiveReasonId` | string | No | Archive reason UUID. Required when moving to an Archived stage, ignored otherwise |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `applicationId` | string | Application UUID |
| `stageId` | string | New interview stage UUID |
### `ashby_create_application`
Creates a new application for a candidate on a job. Optionally specify interview plan, stage, source, and credited user.
@@ -57,23 +95,7 @@ Creates a new application for a candidate on a job. Optionally specify interview
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Created application UUID |
| `status` | string | Application status \(Active, Hired, Archived, Lead\) |
| `candidate` | object | Associated candidate |
| ↳ `id` | string | Candidate UUID |
| ↳ `name` | string | Candidate name |
| `job` | object | Associated job |
| ↳ `id` | string | Job UUID |
| ↳ `title` | string | Job title |
| `currentInterviewStage` | object | Current interview stage |
| ↳ `id` | string | Stage UUID |
| ↳ `title` | string | Stage title |
| ↳ `type` | string | Stage type |
| `source` | object | Application source |
| ↳ `id` | string | Source UUID |
| ↳ `title` | string | Source title |
| `createdAt` | string | ISO 8601 creation timestamp |
| `updatedAt` | string | ISO 8601 last update timestamp |
| `applicationId` | string | Created application UUID |
### `ashby_create_candidate`
@@ -85,10 +107,8 @@ Creates a new candidate record in Ashby.
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Ashby API Key |
| `name` | string | Yes | The candidate full name |
| `email` | string | No | Primary email address for the candidate |
| `emailType` | string | No | Email address type: Personal, Work, or Other \(default Work\) |
| `email` | string | Yes | Primary email address for the candidate |
| `phoneNumber` | string | No | Primary phone number for the candidate |
| `phoneType` | string | No | Phone number type: Personal, Work, or Other \(default Work\) |
| `linkedInUrl` | string | No | LinkedIn profile URL |
| `githubUrl` | string | No | GitHub profile URL |
| `sourceId` | string | No | UUID of the source to attribute the candidate to |
@@ -127,14 +147,7 @@ Creates a note on a candidate in Ashby. Supports plain text and HTML content (bo
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Created note UUID |
| `content` | string | Note content as stored |
| `author` | object | Note author |
| ↳ `id` | string | Author user UUID |
| ↳ `firstName` | string | First name |
| ↳ `lastName` | string | Last name |
| ↳ `email` | string | Email address |
| `createdAt` | string | ISO 8601 creation timestamp |
| `noteId` | string | Created note UUID |
### `ashby_get_application`
@@ -228,7 +241,7 @@ Retrieves full details about a single job by its ID.
| --------- | ---- | ----------- |
| `id` | string | Job UUID |
| `title` | string | Job title |
| `status` | string | Job status \(Open, Closed, Draft, Archived, On Hold\) |
| `status` | string | Job status \(Open, Closed, Draft, Archived\) |
| `employmentType` | string | Employment type \(FullTime, PartTime, Intern, Contract, Temporary\) |
| `departmentId` | string | Department UUID |
| `locationId` | string | Location UUID |
@@ -237,6 +250,58 @@ Retrieves full details about a single job by its ID.
| `createdAt` | string | ISO 8601 creation timestamp |
| `updatedAt` | string | ISO 8601 last update timestamp |
### `ashby_get_job_posting`
Retrieves full details about a single job posting by its ID.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Ashby API Key |
| `jobPostingId` | string | Yes | The UUID of the job posting to fetch |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Job posting UUID |
| `title` | string | Job posting title |
| `jobId` | string | Associated job UUID |
| `locationName` | string | Location name |
| `departmentName` | string | Department name |
| `employmentType` | string | Employment type \(e.g. FullTime, PartTime, Contract\) |
| `descriptionPlain` | string | Job posting description in plain text |
| `isListed` | boolean | Whether the posting is publicly listed |
| `publishedDate` | string | ISO 8601 published date |
| `externalLink` | string | External link to the job posting |
### `ashby_get_offer`
Retrieves full details about a single offer by its ID.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Ashby API Key |
| `offerId` | string | Yes | The UUID of the offer to fetch |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Offer UUID |
| `offerStatus` | string | Offer status \(e.g. WaitingOnCandidateResponse, CandidateAccepted\) |
| `acceptanceStatus` | string | Acceptance status \(e.g. Accepted, Declined, Pending\) |
| `applicationId` | string | Associated application UUID |
| `startDate` | string | Offer start date |
| `salary` | object | Salary details |
| ↳ `currencyCode` | string | ISO 4217 currency code |
| ↳ `value` | number | Salary amount |
| `openingId` | string | Associated opening UUID |
| `createdAt` | string | ISO 8601 creation timestamp \(from latest version\) |
### `ashby_list_applications`
Lists all applications in an Ashby organization with pagination and optional filters for status, job, candidate, and creation date.
@@ -278,6 +343,45 @@ Lists all applications in an Ashby organization with pagination and optional fil
| `moreDataAvailable` | boolean | Whether more pages of results exist |
| `nextCursor` | string | Opaque cursor for fetching the next page |
### `ashby_list_archive_reasons`
Lists all archive reasons configured in Ashby.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Ashby API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `archiveReasons` | array | List of archive reasons |
| ↳ `id` | string | Archive reason UUID |
| ↳ `text` | string | Archive reason text |
| ↳ `reasonType` | string | Reason type |
| ↳ `isArchived` | boolean | Whether the reason is archived |
### `ashby_list_candidate_tags`
Lists all candidate tags configured in Ashby.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Ashby API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tags` | array | List of candidate tags |
| ↳ `id` | string | Tag UUID |
| ↳ `title` | string | Tag title |
| ↳ `isArchived` | boolean | Whether the tag is archived |
### `ashby_list_candidates`
Lists all candidates in an Ashby organization with cursor-based pagination.
@@ -310,6 +414,98 @@ Lists all candidates in an Ashby organization with cursor-based pagination.
| `moreDataAvailable` | boolean | Whether more pages of results exist |
| `nextCursor` | string | Opaque cursor for fetching the next page |
### `ashby_list_custom_fields`
Lists all custom field definitions configured in Ashby.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Ashby API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `customFields` | array | List of custom field definitions |
| ↳ `id` | string | Custom field UUID |
| ↳ `title` | string | Custom field title |
| ↳ `fieldType` | string | Field type \(e.g. String, Number, Boolean\) |
| ↳ `objectType` | string | Object type the field applies to \(e.g. Candidate, Application, Job\) |
| ↳ `isArchived` | boolean | Whether the custom field is archived |
### `ashby_list_departments`
Lists all departments in Ashby.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Ashby API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `departments` | array | List of departments |
| ↳ `id` | string | Department UUID |
| ↳ `name` | string | Department name |
| ↳ `isArchived` | boolean | Whether the department is archived |
| ↳ `parentId` | string | Parent department UUID |
### `ashby_list_interviews`
Lists interview schedules in Ashby, optionally filtered by application or interview stage.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Ashby API Key |
| `applicationId` | string | No | The UUID of the application to list interview schedules for |
| `interviewStageId` | string | No | The UUID of the interview stage to list interview schedules for |
| `cursor` | string | No | Opaque pagination cursor from a previous response nextCursor value |
| `perPage` | number | No | Number of results per page \(default 100\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `interviewSchedules` | array | List of interview schedules |
| ↳ `id` | string | Interview schedule UUID |
| ↳ `applicationId` | string | Associated application UUID |
| ↳ `interviewStageId` | string | Interview stage UUID |
| ↳ `status` | string | Schedule status |
| ↳ `createdAt` | string | ISO 8601 creation timestamp |
| `moreDataAvailable` | boolean | Whether more pages of results exist |
| `nextCursor` | string | Opaque cursor for fetching the next page |
### `ashby_list_job_postings`
Lists all job postings in Ashby.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Ashby API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `jobPostings` | array | List of job postings |
| ↳ `id` | string | Job posting UUID |
| ↳ `title` | string | Job posting title |
| ↳ `jobId` | string | Associated job UUID |
| ↳ `locationName` | string | Location name |
| ↳ `departmentName` | string | Department name |
| ↳ `employmentType` | string | Employment type \(e.g. FullTime, PartTime, Contract\) |
| ↳ `isListed` | boolean | Whether the posting is publicly listed |
| ↳ `publishedDate` | string | ISO 8601 published date |
### `ashby_list_jobs`
Lists all jobs in an Ashby organization. By default returns Open, Closed, and Archived jobs. Specify status to filter.
@@ -339,6 +535,30 @@ Lists all jobs in an Ashby organization. By default returns Open, Closed, and Ar
| `moreDataAvailable` | boolean | Whether more pages of results exist |
| `nextCursor` | string | Opaque cursor for fetching the next page |
### `ashby_list_locations`
Lists all locations configured in Ashby.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Ashby API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `locations` | array | List of locations |
| ↳ `id` | string | Location UUID |
| ↳ `name` | string | Location name |
| ↳ `isArchived` | boolean | Whether the location is archived |
| ↳ `isRemote` | boolean | Whether this is a remote location |
| ↳ `address` | object | Location address |
| ↳ `city` | string | City |
| ↳ `region` | string | State or region |
| ↳ `country` | string | Country |
### `ashby_list_notes`
Lists all notes on a candidate with pagination support.
@@ -386,18 +606,106 @@ Lists all offers with their latest version in an Ashby organization.
| --------- | ---- | ----------- |
| `offers` | array | List of offers |
| ↳ `id` | string | Offer UUID |
| ↳ `status` | string | Offer status |
| ↳ `candidate` | object | Associated candidate |
| ↳ `id` | string | Candidate UUID |
| ↳ `name` | string | Candidate name |
| ↳ `job` | object | Associated job |
| ↳ `id` | string | Job UUID |
| ↳ `title` | string | Job title |
| ↳ `offerStatus` | string | Offer status |
| ↳ `acceptanceStatus` | string | Acceptance status |
| ↳ `applicationId` | string | Associated application UUID |
| ↳ `startDate` | string | Offer start date |
| ↳ `salary` | object | Salary details |
| ↳ `currencyCode` | string | ISO 4217 currency code |
| ↳ `value` | number | Salary amount |
| ↳ `openingId` | string | Associated opening UUID |
| ↳ `createdAt` | string | ISO 8601 creation timestamp |
| ↳ `updatedAt` | string | ISO 8601 last update timestamp |
| `moreDataAvailable` | boolean | Whether more pages of results exist |
| `nextCursor` | string | Opaque cursor for fetching the next page |
### `ashby_list_openings`
Lists all openings in Ashby with pagination.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Ashby API Key |
| `cursor` | string | No | Opaque pagination cursor from a previous response nextCursor value |
| `perPage` | number | No | Number of results per page \(default 100\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `openings` | array | List of openings |
| ↳ `id` | string | Opening UUID |
| ↳ `openingState` | string | Opening state \(Approved, Closed, Draft, Filled, Open\) |
| ↳ `isArchived` | boolean | Whether the opening is archived |
| ↳ `openedAt` | string | ISO 8601 opened timestamp |
| ↳ `closedAt` | string | ISO 8601 closed timestamp |
| `moreDataAvailable` | boolean | Whether more pages of results exist |
| `nextCursor` | string | Opaque cursor for fetching the next page |
### `ashby_list_sources`
Lists all candidate sources configured in Ashby.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Ashby API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `sources` | array | List of sources |
| ↳ `id` | string | Source UUID |
| ↳ `title` | string | Source title |
| ↳ `isArchived` | boolean | Whether the source is archived |
### `ashby_list_users`
Lists all users in Ashby with pagination.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Ashby API Key |
| `cursor` | string | No | Opaque pagination cursor from a previous response nextCursor value |
| `perPage` | number | No | Number of results per page \(default 100\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `users` | array | List of users |
| ↳ `id` | string | User UUID |
| ↳ `firstName` | string | First name |
| ↳ `lastName` | string | Last name |
| ↳ `email` | string | Email address |
| ↳ `isEnabled` | boolean | Whether the user account is enabled |
| ↳ `globalRole` | string | User role \(Organization Admin, Elevated Access, Limited Access, External Recruiter\) |
| `moreDataAvailable` | boolean | Whether more pages of results exist |
| `nextCursor` | string | Opaque cursor for fetching the next page |
### `ashby_remove_candidate_tag`
Removes a tag from a candidate in Ashby.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Ashby API Key |
| `candidateId` | string | Yes | The UUID of the candidate to remove the tag from |
| `tagId` | string | Yes | The UUID of the tag to remove |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the tag was successfully removed |
### `ashby_search_candidates`
Searches for candidates by name and/or email with AND logic. Results are limited to 100 matches. Use candidate.list for full pagination.
@@ -425,6 +733,8 @@ Searches for candidates by name and/or email with AND logic. Results are limited
| ↳ `value` | string | Phone number |
| ↳ `type` | string | Contact type \(Personal, Work, Other\) |
| ↳ `isPrimary` | boolean | Whether this is the primary phone |
| ↳ `createdAt` | string | ISO 8601 creation timestamp |
| ↳ `updatedAt` | string | ISO 8601 last update timestamp |
### `ashby_update_candidate`
@@ -438,9 +748,7 @@ Updates an existing candidate record in Ashby. Only provided fields are changed.
| `candidateId` | string | Yes | The UUID of the candidate to update |
| `name` | string | No | Updated full name |
| `email` | string | No | Updated primary email address |
| `emailType` | string | No | Email address type: Personal, Work, or Other \(default Work\) |
| `phoneNumber` | string | No | Updated primary phone number |
| `phoneType` | string | No | Phone number type: Personal, Work, or Other \(default Work\) |
| `linkedInUrl` | string | No | LinkedIn profile URL |
| `githubUrl` | string | No | GitHub profile URL |
| `websiteUrl` | string | No | Personal website URL |

View File

@@ -0,0 +1,440 @@
---
title: Box
description: Manage files, folders, and e-signatures with Box
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="box"
color="#FFFFFF"
/>
{/* MANUAL-CONTENT-START:intro */}
[Box](https://www.box.com/) is a leading cloud content management and file sharing platform trusted by enterprises worldwide to securely store, manage, and collaborate on files. Box provides robust APIs for automating file operations and integrating with business workflows, including [Box Sign](https://www.box.com/esignature) for native e-signatures.
With the Box integration in Sim, you can:
- **Upload files**: Upload documents, images, and other files to any Box folder
- **Download files**: Retrieve file content from Box for processing in your workflows
- **Get file info**: Access detailed metadata including size, owner, timestamps, tags, and shared links
- **List folder contents**: Browse files and folders with sorting and pagination support
- **Create folders**: Organize your Box storage by creating new folders programmatically
- **Delete files and folders**: Remove content with optional recursive deletion for folders
- **Copy files**: Duplicate files across folders with optional renaming
- **Search**: Find files and folders by name, content, extension, or location
- **Update file metadata**: Rename, move, add descriptions, or tag files
- **Create sign requests**: Send documents for e-signature with one or more signers
- **Track signing status**: Monitor the progress of sign requests
- **List sign requests**: View all sign requests with marker-based pagination
- **Cancel sign requests**: Cancel pending sign requests that are no longer needed
- **Resend sign reminders**: Send reminder notifications to signers who haven't completed signing
These capabilities allow your Sim agents to automate Box operations directly within your workflows — from organizing documents and distributing content to processing uploaded files, managing e-signature workflows for offer letters and contracts, and maintaining structured cloud storage as part of your business processes.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate Box into your workflow to manage files, folders, and e-signatures. Upload and download files, search content, create folders, send documents for e-signature, track signing status, and more.
## Tools
### `box_upload_file`
Upload a file to a Box folder
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `parentFolderId` | string | Yes | The ID of the folder to upload the file to \(use "0" for root\) |
| `file` | file | No | The file to upload \(UserFile object\) |
| `fileContent` | string | No | Legacy: base64 encoded file content |
| `fileName` | string | No | Optional filename override |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | File ID |
| `name` | string | File name |
| `size` | number | File size in bytes |
| `sha1` | string | SHA1 hash of file content |
| `createdAt` | string | Creation timestamp |
| `modifiedAt` | string | Last modified timestamp |
| `parentId` | string | Parent folder ID |
| `parentName` | string | Parent folder name |
### `box_download_file`
Download a file from Box
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileId` | string | Yes | The ID of the file to download |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `file` | file | Downloaded file stored in execution files |
| `content` | string | Base64 encoded file content |
### `box_get_file_info`
Get detailed information about a file in Box
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileId` | string | Yes | The ID of the file to get information about |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | File ID |
| `name` | string | File name |
| `description` | string | File description |
| `size` | number | File size in bytes |
| `sha1` | string | SHA1 hash of file content |
| `createdAt` | string | Creation timestamp |
| `modifiedAt` | string | Last modified timestamp |
| `createdBy` | object | User who created the file |
| `modifiedBy` | object | User who last modified the file |
| `ownedBy` | object | User who owns the file |
| `parentId` | string | Parent folder ID |
| `parentName` | string | Parent folder name |
| `sharedLink` | json | Shared link details |
| `tags` | array | File tags |
| `commentCount` | number | Number of comments |
### `box_list_folder_items`
List files and folders in a Box folder
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `folderId` | string | Yes | The ID of the folder to list items from \(use "0" for root\) |
| `limit` | number | No | Maximum number of items to return per page |
| `offset` | number | No | The offset for pagination |
| `sort` | string | No | Sort field: id, name, date, or size |
| `direction` | string | No | Sort direction: ASC or DESC |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `entries` | array | List of items in the folder |
| ↳ `type` | string | Item type \(file, folder, web_link\) |
| ↳ `id` | string | Item ID |
| ↳ `name` | string | Item name |
| ↳ `size` | number | Item size in bytes |
| ↳ `createdAt` | string | Creation timestamp |
| ↳ `modifiedAt` | string | Last modified timestamp |
| `totalCount` | number | Total number of items in the folder |
| `offset` | number | Current pagination offset |
| `limit` | number | Current pagination limit |
### `box_create_folder`
Create a new folder in Box
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `name` | string | Yes | Name for the new folder |
| `parentFolderId` | string | Yes | The ID of the parent folder \(use "0" for root\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Folder ID |
| `name` | string | Folder name |
| `createdAt` | string | Creation timestamp |
| `modifiedAt` | string | Last modified timestamp |
| `parentId` | string | Parent folder ID |
| `parentName` | string | Parent folder name |
### `box_delete_file`
Delete a file from Box
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileId` | string | Yes | The ID of the file to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `deleted` | boolean | Whether the file was successfully deleted |
| `message` | string | Success confirmation message |
### `box_delete_folder`
Delete a folder from Box
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `folderId` | string | Yes | The ID of the folder to delete |
| `recursive` | boolean | No | Delete folder and all its contents recursively |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `deleted` | boolean | Whether the folder was successfully deleted |
| `message` | string | Success confirmation message |
### `box_copy_file`
Copy a file to another folder in Box
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileId` | string | Yes | The ID of the file to copy |
| `parentFolderId` | string | Yes | The ID of the destination folder |
| `name` | string | No | Optional new name for the copied file |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | File ID |
| `name` | string | File name |
| `size` | number | File size in bytes |
| `sha1` | string | SHA1 hash of file content |
| `createdAt` | string | Creation timestamp |
| `modifiedAt` | string | Last modified timestamp |
| `parentId` | string | Parent folder ID |
| `parentName` | string | Parent folder name |
### `box_search`
Search for files and folders in Box
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `query` | string | Yes | The search query string |
| `limit` | number | No | Maximum number of results to return |
| `offset` | number | No | The offset for pagination |
| `ancestorFolderId` | string | No | Restrict search to a specific folder and its subfolders |
| `fileExtensions` | string | No | Comma-separated file extensions to filter by \(e.g., pdf,docx\) |
| `type` | string | No | Restrict to a specific content type: file, folder, or web_link |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `results` | array | Search results |
| ↳ `type` | string | Item type \(file, folder, web_link\) |
| ↳ `id` | string | Item ID |
| ↳ `name` | string | Item name |
| ↳ `size` | number | Item size in bytes |
| ↳ `createdAt` | string | Creation timestamp |
| ↳ `modifiedAt` | string | Last modified timestamp |
| ↳ `parentId` | string | Parent folder ID |
| ↳ `parentName` | string | Parent folder name |
| `totalCount` | number | Total number of matching results |
### `box_update_file`
Update file info in Box (rename, move, change description, add tags)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileId` | string | Yes | The ID of the file to update |
| `name` | string | No | New name for the file |
| `description` | string | No | New description for the file \(max 256 characters\) |
| `parentFolderId` | string | No | Move the file to a different folder by specifying the folder ID |
| `tags` | string | No | Comma-separated tags to set on the file |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | File ID |
| `name` | string | File name |
| `description` | string | File description |
| `size` | number | File size in bytes |
| `sha1` | string | SHA1 hash of file content |
| `createdAt` | string | Creation timestamp |
| `modifiedAt` | string | Last modified timestamp |
| `createdBy` | object | User who created the file |
| `modifiedBy` | object | User who last modified the file |
| `ownedBy` | object | User who owns the file |
| `parentId` | string | Parent folder ID |
| `parentName` | string | Parent folder name |
| `sharedLink` | json | Shared link details |
| `tags` | array | File tags |
| `commentCount` | number | Number of comments |
### `box_sign_create_request`
Create a new Box Sign request to send documents for e-signature
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `sourceFileIds` | string | Yes | Comma-separated Box file IDs to send for signing |
| `signerEmail` | string | Yes | Primary signer email address |
| `signerRole` | string | No | Primary signer role: signer, approver, or final_copy_reader \(default: signer\) |
| `additionalSigners` | string | No | JSON array of additional signers, e.g. \[\{"email":"user@example.com","role":"signer"\}\] |
| `parentFolderId` | string | No | Box folder ID where signed documents will be stored \(default: user root\) |
| `emailSubject` | string | No | Custom subject line for the signing email |
| `emailMessage` | string | No | Custom message in the signing email body |
| `name` | string | No | Name for the sign request |
| `daysValid` | number | No | Number of days before the request expires \(0-730\) |
| `areRemindersEnabled` | boolean | No | Whether to send automatic signing reminders |
| `areTextSignaturesEnabled` | boolean | No | Whether to allow typed \(text\) signatures |
| `signatureColor` | string | No | Signature color: blue, black, or red |
| `redirectUrl` | string | No | URL to redirect signers to after signing |
| `declinedRedirectUrl` | string | No | URL to redirect signers to after declining |
| `isDocumentPreparationNeeded` | boolean | No | Whether document preparation is needed before sending |
| `externalId` | string | No | External system reference ID |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Sign request ID |
| `status` | string | Request status \(converting, created, sent, viewed, signed, cancelled, declined, expired, error_converting, error_sending, finalizing, error_finalizing\) |
| `name` | string | Sign request name |
| `shortId` | string | Human-readable short ID |
| `signers` | array | List of signers |
| `sourceFiles` | array | Source files for signing |
| `emailSubject` | string | Custom email subject line |
| `emailMessage` | string | Custom email message body |
| `daysValid` | number | Number of days the request is valid |
| `createdAt` | string | Creation timestamp |
| `autoExpireAt` | string | Auto-expiration timestamp |
| `prepareUrl` | string | URL for document preparation \(if preparation is needed\) |
| `senderEmail` | string | Email of the sender |
### `box_sign_get_request`
Get the details and status of a Box Sign request
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `signRequestId` | string | Yes | The ID of the sign request to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Sign request ID |
| `status` | string | Request status \(converting, created, sent, viewed, signed, cancelled, declined, expired, error_converting, error_sending, finalizing, error_finalizing\) |
| `name` | string | Sign request name |
| `shortId` | string | Human-readable short ID |
| `signers` | array | List of signers |
| `sourceFiles` | array | Source files for signing |
| `emailSubject` | string | Custom email subject line |
| `emailMessage` | string | Custom email message body |
| `daysValid` | number | Number of days the request is valid |
| `createdAt` | string | Creation timestamp |
| `autoExpireAt` | string | Auto-expiration timestamp |
| `prepareUrl` | string | URL for document preparation \(if preparation is needed\) |
| `senderEmail` | string | Email of the sender |
### `box_sign_list_requests`
List all Box Sign requests
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `limit` | number | No | Maximum number of sign requests to return \(max 1000\) |
| `marker` | string | No | Pagination marker from a previous response |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `signRequests` | array | List of sign requests |
| ↳ `id` | string | Sign request ID |
| ↳ `status` | string | Request status \(converting, created, sent, viewed, signed, cancelled, declined, expired, error_converting, error_sending, finalizing, error_finalizing\) |
| ↳ `name` | string | Sign request name |
| ↳ `shortId` | string | Human-readable short ID |
| ↳ `signers` | array | List of signers |
| ↳ `sourceFiles` | array | Source files for signing |
| ↳ `emailSubject` | string | Custom email subject line |
| ↳ `emailMessage` | string | Custom email message body |
| ↳ `daysValid` | number | Number of days the request is valid |
| ↳ `createdAt` | string | Creation timestamp |
| ↳ `autoExpireAt` | string | Auto-expiration timestamp |
| ↳ `prepareUrl` | string | URL for document preparation \(if preparation is needed\) |
| ↳ `senderEmail` | string | Email of the sender |
| `count` | number | Number of sign requests returned in this page |
| `nextMarker` | string | Marker for next page of results |
### `box_sign_cancel_request`
Cancel a pending Box Sign request
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `signRequestId` | string | Yes | The ID of the sign request to cancel |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Sign request ID |
| `status` | string | Request status \(converting, created, sent, viewed, signed, cancelled, declined, expired, error_converting, error_sending, finalizing, error_finalizing\) |
| `name` | string | Sign request name |
| `shortId` | string | Human-readable short ID |
| `signers` | array | List of signers |
| `sourceFiles` | array | Source files for signing |
| `emailSubject` | string | Custom email subject line |
| `emailMessage` | string | Custom email message body |
| `daysValid` | number | Number of days the request is valid |
| `createdAt` | string | Creation timestamp |
| `autoExpireAt` | string | Auto-expiration timestamp |
| `prepareUrl` | string | URL for document preparation \(if preparation is needed\) |
| `senderEmail` | string | Email of the sender |
### `box_sign_resend_request`
Resend a Box Sign request to signers who have not yet signed
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `signRequestId` | string | Yes | The ID of the sign request to resend |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Success confirmation message |

View File

@@ -0,0 +1,230 @@
---
title: DocuSign
description: Send documents for e-signature via DocuSign
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="docusign"
color="#FFFFFF"
/>
{/* MANUAL-CONTENT-START:intro */}
[DocuSign](https://www.docusign.com) is the world's leading e-signature platform, enabling businesses to send, sign, and manage agreements digitally. With its powerful eSignature REST API, DocuSign supports the full document lifecycle from creation through completion.
With the DocuSign integration in Sim, you can:
- **Send envelopes**: Create and send documents for e-signature with custom recipients and signing tabs
- **Use templates**: Send envelopes from pre-configured DocuSign templates with role assignments
- **Track status**: Get envelope details including signing progress, timestamps, and recipient status
- **List envelopes**: Search and filter envelopes by date range, status, and text
- **Download documents**: Retrieve signed documents as base64-encoded files
- **Manage recipients**: View signer and CC recipient details and signing status
- **Void envelopes**: Cancel in-progress envelopes with a reason
In Sim, the DocuSign integration enables your agents to automate document workflows end-to-end. Agents can generate agreements, send them for signature, monitor completion, and retrieve signed copies—powering contract management, HR onboarding, sales closings, and compliance processes.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Create and send envelopes for e-signature, use templates, check signing status, download signed documents, and manage recipients with DocuSign.
## Tools
### `docusign_send_envelope`
Create and send a DocuSign envelope with a document for e-signature
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `emailSubject` | string | Yes | Email subject for the envelope |
| `emailBody` | string | No | Email body message |
| `signerEmail` | string | Yes | Email address of the signer |
| `signerName` | string | Yes | Full name of the signer |
| `ccEmail` | string | No | Email address of carbon copy recipient |
| `ccName` | string | No | Full name of carbon copy recipient |
| `file` | file | No | Document file to send for signature |
| `status` | string | No | Envelope status: "sent" to send immediately, "created" for draft \(default: "sent"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `envelopeId` | string | Created envelope ID |
| `status` | string | Envelope status |
| `statusDateTime` | string | Status change datetime |
| `uri` | string | Envelope URI |
### `docusign_create_from_template`
Create and send a DocuSign envelope using a pre-built template
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `templateId` | string | Yes | DocuSign template ID to use |
| `emailSubject` | string | No | Override email subject \(uses template default if not set\) |
| `emailBody` | string | No | Override email body message |
| `templateRoles` | string | Yes | JSON array of template roles, e.g. \[\{"roleName":"Signer","name":"John","email":"john@example.com"\}\] |
| `status` | string | No | Envelope status: "sent" to send immediately, "created" for draft \(default: "sent"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `envelopeId` | string | Created envelope ID |
| `status` | string | Envelope status |
| `statusDateTime` | string | Status change datetime |
| `uri` | string | Envelope URI |
### `docusign_get_envelope`
Get the details and status of a DocuSign envelope
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `envelopeId` | string | Yes | The envelope ID to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `envelopeId` | string | Envelope ID |
| `status` | string | Envelope status \(created, sent, delivered, completed, declined, voided\) |
| `emailSubject` | string | Email subject line |
| `sentDateTime` | string | When the envelope was sent |
| `completedDateTime` | string | When all recipients completed signing |
| `createdDateTime` | string | When the envelope was created |
| `statusChangedDateTime` | string | When the status last changed |
| `voidedReason` | string | Reason the envelope was voided |
| `signerCount` | number | Number of signers |
| `documentCount` | number | Number of documents |
### `docusign_list_envelopes`
List envelopes from your DocuSign account with optional filters
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fromDate` | string | No | Start date filter \(ISO 8601\). Defaults to 30 days ago |
| `toDate` | string | No | End date filter \(ISO 8601\) |
| `envelopeStatus` | string | No | Filter by status: created, sent, delivered, completed, declined, voided |
| `searchText` | string | No | Search text to filter envelopes |
| `count` | string | No | Maximum number of envelopes to return \(default: 25\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `envelopes` | array | Array of DocuSign envelopes |
| ↳ `envelopeId` | string | Unique envelope identifier |
| ↳ `status` | string | Envelope status \(created, sent, delivered, completed, declined, voided\) |
| ↳ `emailSubject` | string | Email subject line |
| ↳ `sentDateTime` | string | ISO 8601 datetime when envelope was sent |
| ↳ `completedDateTime` | string | ISO 8601 datetime when envelope was completed |
| ↳ `createdDateTime` | string | ISO 8601 datetime when envelope was created |
| ↳ `statusChangedDateTime` | string | ISO 8601 datetime of last status change |
| `totalSetSize` | number | Total number of matching envelopes |
| `resultSetSize` | number | Number of envelopes returned in this response |
### `docusign_void_envelope`
Void (cancel) a sent DocuSign envelope that has not yet been completed
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `envelopeId` | string | Yes | The envelope ID to void |
| `voidedReason` | string | Yes | Reason for voiding the envelope |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `envelopeId` | string | Voided envelope ID |
| `status` | string | Envelope status \(voided\) |
### `docusign_download_document`
Download a signed document from a completed DocuSign envelope
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `envelopeId` | string | Yes | The envelope ID containing the document |
| `documentId` | string | No | Specific document ID to download, or "combined" for all documents merged \(default: "combined"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `base64Content` | string | Base64-encoded document content |
| `mimeType` | string | MIME type of the document |
| `fileName` | string | Original file name |
### `docusign_list_templates`
List available templates in your DocuSign account
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `searchText` | string | No | Search text to filter templates by name |
| `count` | string | No | Maximum number of templates to return |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `templates` | array | Array of DocuSign templates |
| ↳ `templateId` | string | Template identifier |
| ↳ `name` | string | Template name |
| ↳ `description` | string | Template description |
| ↳ `shared` | boolean | Whether template is shared |
| ↳ `created` | string | ISO 8601 creation date |
| ↳ `lastModified` | string | ISO 8601 last modified date |
| `totalSetSize` | number | Total number of matching templates |
| `resultSetSize` | number | Number of templates returned in this response |
### `docusign_list_recipients`
Get the recipient status details for a DocuSign envelope
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `envelopeId` | string | Yes | The envelope ID to get recipients for |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `signers` | array | Array of DocuSign recipients |
| ↳ `recipientId` | string | Recipient identifier |
| ↳ `name` | string | Recipient name |
| ↳ `email` | string | Recipient email address |
| ↳ `status` | string | Recipient signing status \(sent, delivered, completed, declined\) |
| ↳ `signedDateTime` | string | ISO 8601 datetime when recipient signed |
| ↳ `deliveredDateTime` | string | ISO 8601 datetime when delivered to recipient |
| `carbonCopies` | array | Array of carbon copy recipients |
| ↳ `recipientId` | string | Recipient ID |
| ↳ `name` | string | Recipient name |
| ↳ `email` | string | Recipient email |
| ↳ `status` | string | Recipient status |

View File

@@ -53,6 +53,9 @@ Extract structured content from web pages with comprehensive metadata support. C
| `url` | string | Yes | The URL to scrape content from \(e.g., "https://example.com/page"\) |
| `scrapeOptions` | json | No | Options for content scraping |
| `apiKey` | string | Yes | Firecrawl API key |
| `pricing` | custom | No | No description |
| `metadata` | string | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -86,6 +89,9 @@ Search for information on the web using Firecrawl
| --------- | ---- | -------- | ----------- |
| `query` | string | Yes | The search query to use |
| `apiKey` | string | Yes | Firecrawl API key |
| `pricing` | custom | No | No description |
| `metadata` | string | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -123,6 +129,9 @@ Crawl entire websites and extract structured content from all accessible pages
| `includePaths` | json | No | URL paths to include in crawling \(e.g., \["/docs/*", "/api/*"\]\). Only these paths will be crawled |
| `onlyMainContent` | boolean | No | Extract only main content from pages |
| `apiKey` | string | Yes | Firecrawl API Key |
| `pricing` | custom | No | No description |
| `metadata` | string | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -142,7 +151,6 @@ Crawl entire websites and extract structured content from all accessible pages
| ↳ `statusCode` | number | HTTP status code |
| ↳ `ogLocaleAlternate` | array | Alternate locale versions |
| `total` | number | Total number of pages found during crawl |
| `creditsUsed` | number | Number of credits consumed by the crawl operation |
### `firecrawl_map`
@@ -161,6 +169,9 @@ Get a complete list of URLs from any website quickly and reliably. Useful for di
| `timeout` | number | No | Request timeout in milliseconds |
| `location` | json | No | Geographic context for proxying \(country, languages\) |
| `apiKey` | string | Yes | Firecrawl API key |
| `pricing` | custom | No | No description |
| `metadata` | string | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -187,6 +198,9 @@ Extract structured data from entire webpages using natural language prompts and
| `ignoreInvalidURLs` | boolean | No | Skip invalid URLs in the array \(default: true\) |
| `scrapeOptions` | json | No | Advanced scraping configuration options |
| `apiKey` | string | Yes | Firecrawl API key |
| `pricing` | custom | No | No description |
| `metadata` | string | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -217,7 +231,6 @@ Autonomous web data extraction agent. Searches and gathers information based on
| `success` | boolean | Whether the agent operation was successful |
| `status` | string | Current status of the agent job \(processing, completed, failed\) |
| `data` | object | Extracted data from the agent |
| `creditsUsed` | number | Number of credits consumed by this agent task |
| `expiresAt` | string | Timestamp when the results expire \(24 hours\) |
| `sources` | object | Array of source URLs used by the agent |

View File

@@ -46,6 +46,8 @@ Search for books using the Google Books API
| `startIndex` | number | No | Index of the first result to return \(for pagination\) |
| `maxResults` | number | No | Maximum number of results to return \(1-40\) |
| `langRestrict` | string | No | Restrict results to a specific language \(ISO 639-1 code\) |
| `pricing` | per_request | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -82,6 +84,8 @@ Get detailed information about a specific book volume
| `apiKey` | string | Yes | Google Books API key |
| `volumeId` | string | Yes | The ID of the volume to retrieve |
| `projection` | string | No | Projection level \(full, lite\) |
| `pricing` | per_request | No | No description |
| `rateLimit` | string | No | No description |
#### Output

View File

@@ -50,6 +50,8 @@ Get current air quality data for a location
| `lat` | number | Yes | Latitude coordinate |
| `lng` | number | Yes | Longitude coordinate |
| `languageCode` | string | No | Language code for the response \(e.g., "en", "es"\) |
| `pricing` | per_request | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -91,6 +93,8 @@ Get directions and route information between two locations
| `waypoints` | json | No | Array of intermediate waypoints |
| `units` | string | No | Unit system: metric or imperial |
| `language` | string | No | Language code for results \(e.g., en, es, fr\) |
| `pricing` | per_request | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -135,6 +139,8 @@ Calculate travel distance and time between multiple origins and destinations
| `avoid` | string | No | Features to avoid: tolls, highways, or ferries |
| `units` | string | No | Unit system: metric or imperial |
| `language` | string | No | Language code for results \(e.g., en, es, fr\) |
| `pricing` | per_request | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -163,6 +169,8 @@ Get elevation data for a location
| `apiKey` | string | Yes | Google Maps API key |
| `lat` | number | Yes | Latitude coordinate |
| `lng` | number | Yes | Longitude coordinate |
| `pricing` | per_request | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -185,6 +193,8 @@ Convert an address into geographic coordinates (latitude and longitude)
| `address` | string | Yes | The address to geocode |
| `language` | string | No | Language code for results \(e.g., en, es, fr\) |
| `region` | string | No | Region bias as a ccTLD code \(e.g., us, uk\) |
| `pricing` | per_request | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -217,6 +227,8 @@ Geolocate a device using WiFi access points, cell towers, or IP address
| `considerIp` | boolean | No | Whether to use IP address for geolocation \(default: true\) |
| `cellTowers` | array | No | Array of cell tower objects with cellId, locationAreaCode, mobileCountryCode, mobileNetworkCode |
| `wifiAccessPoints` | array | No | Array of WiFi access point objects with macAddress \(required\), signalStrength, etc. |
| `pricing` | per_request | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -238,6 +250,8 @@ Get detailed information about a specific place
| `placeId` | string | Yes | Google Place ID |
| `fields` | string | No | Comma-separated list of fields to return |
| `language` | string | No | Language code for results \(e.g., en, es, fr\) |
| `pricing` | per_request | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -290,6 +304,8 @@ Search for places using a text query
| `type` | string | No | Place type filter \(e.g., restaurant, cafe, hotel\) |
| `language` | string | No | Language code for results \(e.g., en, es, fr\) |
| `region` | string | No | Region bias as a ccTLD code \(e.g., us, uk\) |
| `pricing` | per_request | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -322,6 +338,8 @@ Convert geographic coordinates (latitude and longitude) into a human-readable ad
| `lat` | number | Yes | Latitude coordinate |
| `lng` | number | Yes | Longitude coordinate |
| `language` | string | No | Language code for results \(e.g., en, es, fr\) |
| `pricing` | per_request | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -346,6 +364,8 @@ Snap GPS coordinates to the nearest road segment
| `apiKey` | string | Yes | Google Maps API key with Roads API enabled |
| `path` | string | Yes | Pipe-separated list of lat,lng coordinates \(e.g., "60.170880,24.942795\|60.170879,24.942796"\) |
| `interpolate` | boolean | No | Whether to interpolate additional points along the road |
| `pricing` | per_request | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -399,6 +419,8 @@ Get timezone information for a location
| `lng` | number | Yes | Longitude coordinate |
| `timestamp` | number | No | Unix timestamp to determine DST offset \(defaults to current time\) |
| `language` | string | No | Language code for timezone name \(e.g., en, es, fr\) |
| `pricing` | per_request | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -424,6 +446,8 @@ Validate and standardize a postal address
| `regionCode` | string | No | ISO 3166-1 alpha-2 country code \(e.g., "US", "CA"\) |
| `locality` | string | No | City or locality name |
| `enableUspsCass` | boolean | No | Enable USPS CASS validation for US addresses |
| `pricing` | per_request | No | No description |
| `rateLimit` | string | No | No description |
#### Output

View File

@@ -55,6 +55,8 @@ Analyze a webpage for performance, accessibility, SEO, and best practices using
| `category` | string | No | Lighthouse categories to analyze \(comma-separated\): performance, accessibility, best-practices, seo |
| `strategy` | string | No | Analysis strategy: desktop or mobile |
| `locale` | string | No | Locale for results \(e.g., en, fr, de\) |
| `pricing` | per_request | No | No description |
| `rateLimit` | string | No | No description |
#### Output

View File

@@ -43,6 +43,9 @@ Translate text between languages using the Google Cloud Translation API. Support
| `target` | string | Yes | Target language code \(e.g., "es", "fr", "de", "ja"\) |
| `source` | string | No | Source language code. If omitted, the API will auto-detect the source language. |
| `format` | string | No | Format of the text: "text" for plain text, "html" for HTML content |
| `pricing` | custom | No | No description |
| `metadata` | string | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -61,6 +64,9 @@ Detect the language of text using the Google Cloud Translation API.
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Google Cloud API key with Cloud Translation API enabled |
| `text` | string | Yes | The text to detect the language of |
| `pricing` | custom | No | No description |
| `metadata` | string | No | No description |
| `rateLimit` | string | No | No description |
#### Output

View File

@@ -138,6 +138,26 @@ Get the full transcript of a recording
| ↳ `end` | number | End timestamp in ms |
| ↳ `text` | string | Transcript text |
### `grain_list_views`
List available Grain views for webhook subscriptions
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Grain API key \(Personal Access Token\) |
| `typeFilter` | string | No | Optional view type filter: recordings, highlights, or stories |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `views` | array | Array of Grain views |
| ↳ `id` | string | View UUID |
| ↳ `name` | string | View name |
| ↳ `type` | string | View type: recordings, highlights, or stories |
### `grain_list_teams`
List all teams in the workspace
@@ -185,15 +205,9 @@ Create a webhook to receive recording events
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Grain API key \(Personal Access Token\) |
| `hookUrl` | string | Yes | Webhook endpoint URL \(e.g., "https://example.com/webhooks/grain"\) |
| `hookType` | string | Yes | Type of webhook: "recording_added" or "upload_status" |
| `filterBeforeDatetime` | string | No | Filter: recordings before this ISO8601 date \(e.g., "2024-01-15T00:00:00Z"\) |
| `filterAfterDatetime` | string | No | Filter: recordings after this ISO8601 date \(e.g., "2024-01-01T00:00:00Z"\) |
| `filterParticipantScope` | string | No | Filter: "internal" or "external" |
| `filterTeamId` | string | No | Filter: specific team UUID \(e.g., "a1b2c3d4-e5f6-7890-abcd-ef1234567890"\) |
| `filterMeetingTypeId` | string | No | Filter: specific meeting type UUID \(e.g., "a1b2c3d4-e5f6-7890-abcd-ef1234567890"\) |
| `includeHighlights` | boolean | No | Include highlights in webhook payload |
| `includeParticipants` | boolean | No | Include participants in webhook payload |
| `includeAiSummary` | boolean | No | Include AI summary in webhook payload |
| `viewId` | string | Yes | Grain view ID from GET /_/public-api/views |
| `actions` | array | No | Optional list of actions to subscribe to: added, updated, removed |
| `items` | string | No | No description |
#### Output
@@ -202,9 +216,8 @@ Create a webhook to receive recording events
| `id` | string | Hook UUID |
| `enabled` | boolean | Whether hook is active |
| `hook_url` | string | The webhook URL |
| `hook_type` | string | Type of hook: recording_added or upload_status |
| `filter` | object | Applied filters |
| `include` | object | Included fields |
| `view_id` | string | Grain view ID for the webhook |
| `actions` | array | Configured actions for the webhook |
| `inserted_at` | string | ISO8601 creation timestamp |
### `grain_list_hooks`
@@ -225,9 +238,8 @@ List all webhooks for the account
| ↳ `id` | string | Hook UUID |
| ↳ `enabled` | boolean | Whether hook is active |
| ↳ `hook_url` | string | Webhook URL |
| ↳ `hook_type` | string | Type: recording_added or upload_status |
| ↳ `filter` | object | Applied filters |
| ↳ `include` | object | Included fields |
| ↳ `view_id` | string | Grain view ID |
| ↳ `actions` | array | Configured actions |
| ↳ `inserted_at` | string | Creation timestamp |
### `grain_delete_hook`

View File

@@ -0,0 +1,255 @@
---
title: Infisical
description: Manage secrets with Infisical
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="infisical"
color="#F7FE62"
/>
{/* MANUAL-CONTENT-START:intro */}
[Infisical](https://infisical.com/) is an open-source secrets management platform that helps teams centralize and manage application secrets, environment variables, and sensitive configuration data across their infrastructure. This integration brings Infisical's secrets management capabilities directly into Sim workflows.
With Infisical in Sim, you can:
- **List secrets**: Retrieve all secrets from a project environment with filtering by path, tags, and recursive subdirectory support
- **Get a secret**: Fetch a specific secret by name, with optional version pinning and secret reference expansion
- **Create secrets**: Add new secrets to any project environment with support for comments, paths, and tag assignments
- **Update secrets**: Modify existing secret values, comments, names, and tags
- **Delete secrets**: Remove secrets from a project environment
In Sim, the Infisical integration enables your agents to programmatically manage secrets as part of automated workflows — for example, rotating credentials, syncing environment variables across environments, or auditing secret usage. Simply configure the Infisical block with your API key, select the operation, and provide the project ID and environment slug to get started.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate Infisical into your workflow. List, get, create, update, and delete secrets across project environments.
## Tools
### `infisical_list_secrets`
List all secrets in a project environment. Returns secret keys, values, comments, tags, and metadata.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Infisical API token |
| `baseUrl` | string | No | Infisical instance URL \(default: "https://us.infisical.com"\). Use "https://eu.infisical.com" for EU Cloud or your self-hosted URL. |
| `projectId` | string | Yes | The ID of the project to list secrets from |
| `environment` | string | Yes | The environment slug \(e.g., "dev", "staging", "prod"\) |
| `secretPath` | string | No | The path of the secrets \(default: "/"\) |
| `recursive` | boolean | No | Whether to fetch secrets recursively from subdirectories |
| `expandSecretReferences` | boolean | No | Whether to expand secret references \(default: true\) |
| `viewSecretValue` | boolean | No | Whether to include secret values in the response \(default: true\) |
| `includeImports` | boolean | No | Whether to include imported secrets \(default: true\) |
| `tagSlugs` | string | No | Comma-separated tag slugs to filter secrets by |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `secrets` | array | Array of secrets |
| ↳ `id` | string | Secret ID |
| ↳ `workspace` | string | Workspace/project ID |
| ↳ `secretKey` | string | Secret name/key |
| ↳ `secretValue` | string | Secret value |
| ↳ `secretComment` | string | Secret comment |
| ↳ `secretPath` | string | Secret path |
| ↳ `version` | number | Secret version |
| ↳ `type` | string | Secret type \(shared or personal\) |
| ↳ `environment` | string | Environment slug |
| ↳ `tags` | array | Tags attached to the secret |
| ↳ `id` | string | Tag ID |
| ↳ `slug` | string | Tag slug |
| ↳ `color` | string | Tag color |
| ↳ `name` | string | Tag name |
| ↳ `secretMetadata` | array | Custom metadata key-value pairs |
| ↳ `key` | string | Metadata key |
| ↳ `value` | string | Metadata value |
| ↳ `createdAt` | string | Creation timestamp |
| ↳ `updatedAt` | string | Last update timestamp |
| `count` | number | Total number of secrets returned |
### `infisical_get_secret`
Retrieve a single secret by name from a project environment.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Infisical API token |
| `baseUrl` | string | No | Infisical instance URL \(default: "https://us.infisical.com"\). Use "https://eu.infisical.com" for EU Cloud or your self-hosted URL. |
| `projectId` | string | Yes | The ID of the project |
| `environment` | string | Yes | The environment slug \(e.g., "dev", "staging", "prod"\) |
| `secretName` | string | Yes | The name of the secret to retrieve |
| `secretPath` | string | No | The path of the secret \(default: "/"\) |
| `version` | number | No | Specific version of the secret to retrieve |
| `type` | string | No | Secret type: "shared" or "personal" \(default: "shared"\) |
| `viewSecretValue` | boolean | No | Whether to include the secret value in the response \(default: true\) |
| `expandSecretReferences` | boolean | No | Whether to expand secret references \(default: true\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `secret` | object | The retrieved secret |
| ↳ `id` | string | Secret ID |
| ↳ `workspace` | string | Workspace/project ID |
| ↳ `secretKey` | string | Secret name/key |
| ↳ `secretValue` | string | Secret value |
| ↳ `secretComment` | string | Secret comment |
| ↳ `secretPath` | string | Secret path |
| ↳ `version` | number | Secret version |
| ↳ `type` | string | Secret type \(shared or personal\) |
| ↳ `environment` | string | Environment slug |
| ↳ `tags` | array | Tags attached to the secret |
| ↳ `id` | string | Tag ID |
| ↳ `slug` | string | Tag slug |
| ↳ `color` | string | Tag color |
| ↳ `name` | string | Tag name |
| ↳ `secretMetadata` | array | Custom metadata key-value pairs |
| ↳ `key` | string | Metadata key |
| ↳ `value` | string | Metadata value |
| ↳ `createdAt` | string | Creation timestamp |
| ↳ `updatedAt` | string | Last update timestamp |
### `infisical_create_secret`
Create a new secret in a project environment.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Infisical API token |
| `baseUrl` | string | No | Infisical instance URL \(default: "https://us.infisical.com"\). Use "https://eu.infisical.com" for EU Cloud or your self-hosted URL. |
| `projectId` | string | Yes | The ID of the project |
| `environment` | string | Yes | The environment slug \(e.g., "dev", "staging", "prod"\) |
| `secretName` | string | Yes | The name of the secret to create |
| `secretValue` | string | Yes | The value of the secret |
| `secretPath` | string | No | The path for the secret \(default: "/"\) |
| `secretComment` | string | No | A comment for the secret |
| `type` | string | No | Secret type: "shared" or "personal" \(default: "shared"\) |
| `tagIds` | string | No | Comma-separated tag IDs to attach to the secret |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `secret` | object | The created secret |
| ↳ `id` | string | Secret ID |
| ↳ `workspace` | string | Workspace/project ID |
| ↳ `secretKey` | string | Secret name/key |
| ↳ `secretValue` | string | Secret value |
| ↳ `secretComment` | string | Secret comment |
| ↳ `secretPath` | string | Secret path |
| ↳ `version` | number | Secret version |
| ↳ `type` | string | Secret type \(shared or personal\) |
| ↳ `environment` | string | Environment slug |
| ↳ `tags` | array | Tags attached to the secret |
| ↳ `id` | string | Tag ID |
| ↳ `slug` | string | Tag slug |
| ↳ `color` | string | Tag color |
| ↳ `name` | string | Tag name |
| ↳ `secretMetadata` | array | Custom metadata key-value pairs |
| ↳ `key` | string | Metadata key |
| ↳ `value` | string | Metadata value |
| ↳ `createdAt` | string | Creation timestamp |
| ↳ `updatedAt` | string | Last update timestamp |
### `infisical_update_secret`
Update an existing secret in a project environment.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Infisical API token |
| `baseUrl` | string | No | Infisical instance URL \(default: "https://us.infisical.com"\). Use "https://eu.infisical.com" for EU Cloud or your self-hosted URL. |
| `projectId` | string | Yes | The ID of the project |
| `environment` | string | Yes | The environment slug \(e.g., "dev", "staging", "prod"\) |
| `secretName` | string | Yes | The name of the secret to update |
| `secretValue` | string | No | The new value for the secret |
| `secretPath` | string | No | The path of the secret \(default: "/"\) |
| `secretComment` | string | No | A comment for the secret |
| `newSecretName` | string | No | New name for the secret \(to rename it\) |
| `type` | string | No | Secret type: "shared" or "personal" \(default: "shared"\) |
| `tagIds` | string | No | Comma-separated tag IDs to set on the secret |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `secret` | object | The updated secret |
| ↳ `id` | string | Secret ID |
| ↳ `workspace` | string | Workspace/project ID |
| ↳ `secretKey` | string | Secret name/key |
| ↳ `secretValue` | string | Secret value |
| ↳ `secretComment` | string | Secret comment |
| ↳ `secretPath` | string | Secret path |
| ↳ `version` | number | Secret version |
| ↳ `type` | string | Secret type \(shared or personal\) |
| ↳ `environment` | string | Environment slug |
| ↳ `tags` | array | Tags attached to the secret |
| ↳ `id` | string | Tag ID |
| ↳ `slug` | string | Tag slug |
| ↳ `color` | string | Tag color |
| ↳ `name` | string | Tag name |
| ↳ `secretMetadata` | array | Custom metadata key-value pairs |
| ↳ `key` | string | Metadata key |
| ↳ `value` | string | Metadata value |
| ↳ `createdAt` | string | Creation timestamp |
| ↳ `updatedAt` | string | Last update timestamp |
### `infisical_delete_secret`
Delete a secret from a project environment.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Infisical API token |
| `baseUrl` | string | No | Infisical instance URL \(default: "https://us.infisical.com"\). Use "https://eu.infisical.com" for EU Cloud or your self-hosted URL. |
| `projectId` | string | Yes | The ID of the project |
| `environment` | string | Yes | The environment slug \(e.g., "dev", "staging", "prod"\) |
| `secretName` | string | Yes | The name of the secret to delete |
| `secretPath` | string | No | The path of the secret \(default: "/"\) |
| `type` | string | No | Secret type: "shared" or "personal" \(default: "shared"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `secret` | object | The deleted secret |
| ↳ `id` | string | Secret ID |
| ↳ `workspace` | string | Workspace/project ID |
| ↳ `secretKey` | string | Secret name/key |
| ↳ `secretValue` | string | Secret value |
| ↳ `secretComment` | string | Secret comment |
| ↳ `secretPath` | string | Secret path |
| ↳ `version` | number | Secret version |
| ↳ `type` | string | Secret type \(shared or personal\) |
| ↳ `environment` | string | Environment slug |
| ↳ `tags` | array | Tags attached to the secret |
| ↳ `id` | string | Tag ID |
| ↳ `slug` | string | Tag slug |
| ↳ `color` | string | Tag color |
| ↳ `name` | string | Tag name |
| ↳ `secretMetadata` | array | Custom metadata key-value pairs |
| ↳ `key` | string | Metadata key |
| ↳ `value` | string | Metadata value |
| ↳ `createdAt` | string | Creation timestamp |
| ↳ `updatedAt` | string | Last update timestamp |

View File

@@ -64,6 +64,7 @@ Extract and process web content into clean, LLM-friendly text using Jina AI Read
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `content` | string | The extracted content from the URL, processed into clean, LLM-friendly text |
| `tokensUsed` | number | Number of Jina tokens consumed by this request |
### `jina_search`
@@ -97,5 +98,6 @@ Search the web and return top 5 results with LLM-friendly content. Each result i
| ↳ `content` | string | LLM-friendly extracted content |
| ↳ `usage` | object | Token usage information |
| ↳ `tokens` | number | Number of tokens consumed by this request |
| `tokensUsed` | number | Number of Jina tokens consumed by this request |

View File

@@ -122,6 +122,37 @@ Create a new document in a knowledge base
| `message` | string | Success or error message describing the operation result |
| `documentId` | string | ID of the created document |
### `knowledge_upsert_document`
Create or update a document in a knowledge base. If a document with the given ID or filename already exists, it will be replaced with the new content.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `knowledgeBaseId` | string | Yes | ID of the knowledge base containing the document |
| `documentId` | string | No | Optional ID of an existing document to update. If not provided, lookup is done by filename. |
| `name` | string | Yes | Name of the document |
| `content` | string | Yes | Content of the document |
| `documentTags` | json | No | Document tags |
| `documentTags` | string | No | No description |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `data` | object | Information about the upserted document |
| ↳ `documentId` | string | Document ID |
| ↳ `documentName` | string | Document name |
| ↳ `type` | string | Document type |
| ↳ `enabled` | boolean | Whether the document is enabled |
| ↳ `isUpdate` | boolean | Whether an existing document was replaced |
| ↳ `previousDocumentId` | string | ID of the document that was replaced, if any |
| ↳ `createdAt` | string | Creation timestamp |
| ↳ `updatedAt` | string | Last update timestamp |
| `message` | string | Success or error message describing the operation result |
| `documentId` | string | ID of the upserted document |
### `knowledge_list_tags`
List all tag definitions for a knowledge base

View File

@@ -51,6 +51,9 @@ Search the web for information using Linkup
| `includeDomains` | string | No | Comma-separated list of domain names to restrict search results to |
| `includeInlineCitations` | boolean | No | Add inline citations to answers \(only applies when outputType is "sourcedAnswer"\) |
| `includeSources` | boolean | No | Include sources in response |
| `pricing` | custom | No | No description |
| `metadata` | string | No | No description |
| `rateLimit` | string | No | No description |
#### Output

View File

@@ -13,6 +13,7 @@
"asana",
"ashby",
"attio",
"box",
"brandfetch",
"browser_use",
"calcom",
@@ -27,6 +28,7 @@
"datadog",
"devin",
"discord",
"docusign",
"dropbox",
"dspy",
"dub",
@@ -75,6 +77,7 @@
"image_generator",
"imap",
"incidentio",
"infisical",
"intercom",
"jina",
"jira",
@@ -92,6 +95,7 @@
"mailgun",
"mem0",
"memory",
"microsoft_ad",
"microsoft_dataverse",
"microsoft_excel",
"microsoft_planner",
@@ -102,6 +106,7 @@
"neo4j",
"notion",
"obsidian",
"okta",
"onedrive",
"onepassword",
"openai",
@@ -161,6 +166,7 @@
"whatsapp",
"wikipedia",
"wordpress",
"workday",
"x",
"youtube",
"zendesk",

View File

@@ -0,0 +1,336 @@
---
title: Azure AD
description: Manage users and groups in Azure AD (Microsoft Entra ID)
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="microsoft_ad"
color="#0078D4"
/>
{/* MANUAL-CONTENT-START:intro */}
[Azure Active Directory](https://entra.microsoft.com) (now Microsoft Entra ID) is Microsoft's cloud-based identity and access management service. It helps organizations manage users, groups, and access to applications and resources across cloud and on-premises environments.
With the Azure AD integration in Sim, you can:
- **Manage users**: List, create, update, and delete user accounts in your directory
- **Manage groups**: Create and configure security groups and Microsoft 365 groups
- **Control group membership**: Add and remove members from groups programmatically
- **Query directory data**: Search and filter users and groups using OData expressions
- **Automate onboarding/offboarding**: Create new user accounts with initial passwords and enable/disable accounts as part of HR workflows
In Sim, the Azure AD integration enables your agents to programmatically manage your organization's identity infrastructure. This allows for automation scenarios such as provisioning new employees, updating user profiles in bulk, managing team group memberships, and auditing directory data. By connecting Sim with Azure AD, you can streamline identity lifecycle management and ensure your directory stays in sync with your organization's needs.
## Need Help?
If you encounter issues with the Azure AD integration, contact us at [help@sim.ai](mailto:help@sim.ai)
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate Azure Active Directory into your workflows. List, create, update, and delete users and groups. Manage group memberships programmatically.
## Tools
### `microsoft_ad_list_users`
List users in Azure AD (Microsoft Entra ID)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `top` | number | No | Maximum number of users to return \(default 100, max 999\) |
| `filter` | string | No | OData filter expression \(e.g., "department eq \'Sales\'"\) |
| `search` | string | No | Search string to filter users by displayName or mail |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `users` | array | List of users |
| `userCount` | number | Number of users returned |
### `microsoft_ad_get_user`
Get a user by ID or user principal name from Azure AD
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `userId` | string | Yes | User ID or user principal name \(e.g., "user@example.com"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `user` | object | User details |
| ↳ `id` | string | User ID |
| ↳ `displayName` | string | Display name |
| ↳ `givenName` | string | First name |
| ↳ `surname` | string | Last name |
| ↳ `userPrincipalName` | string | User principal name \(email\) |
| ↳ `mail` | string | Email address |
| ↳ `jobTitle` | string | Job title |
| ↳ `department` | string | Department |
| ↳ `officeLocation` | string | Office location |
| ↳ `mobilePhone` | string | Mobile phone number |
| ↳ `accountEnabled` | boolean | Whether the account is enabled |
### `microsoft_ad_create_user`
Create a new user in Azure AD (Microsoft Entra ID)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `displayName` | string | Yes | Display name for the user |
| `mailNickname` | string | Yes | Mail alias for the user |
| `userPrincipalName` | string | Yes | User principal name \(e.g., "user@example.com"\) |
| `password` | string | Yes | Initial password for the user |
| `accountEnabled` | boolean | Yes | Whether the account is enabled |
| `givenName` | string | No | First name |
| `surname` | string | No | Last name |
| `jobTitle` | string | No | Job title |
| `department` | string | No | Department |
| `officeLocation` | string | No | Office location |
| `mobilePhone` | string | No | Mobile phone number |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `user` | object | Created user details |
| ↳ `id` | string | User ID |
| ↳ `displayName` | string | Display name |
| ↳ `givenName` | string | First name |
| ↳ `surname` | string | Last name |
| ↳ `userPrincipalName` | string | User principal name \(email\) |
| ↳ `mail` | string | Email address |
| ↳ `jobTitle` | string | Job title |
| ↳ `department` | string | Department |
| ↳ `officeLocation` | string | Office location |
| ↳ `mobilePhone` | string | Mobile phone number |
| ↳ `accountEnabled` | boolean | Whether the account is enabled |
### `microsoft_ad_update_user`
Update user properties in Azure AD (Microsoft Entra ID)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `userId` | string | Yes | User ID or user principal name |
| `displayName` | string | No | Display name |
| `givenName` | string | No | First name |
| `surname` | string | No | Last name |
| `jobTitle` | string | No | Job title |
| `department` | string | No | Department |
| `officeLocation` | string | No | Office location |
| `mobilePhone` | string | No | Mobile phone number |
| `accountEnabled` | boolean | No | Whether the account is enabled |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `updated` | boolean | Whether the update was successful |
| `userId` | string | ID of the updated user |
### `microsoft_ad_delete_user`
Delete a user from Azure AD (Microsoft Entra ID). The user is moved to a temporary container and can be restored within 30 days.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `userId` | string | Yes | User ID or user principal name |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `deleted` | boolean | Whether the deletion was successful |
| `userId` | string | ID of the deleted user |
### `microsoft_ad_list_groups`
List groups in Azure AD (Microsoft Entra ID)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `top` | number | No | Maximum number of groups to return \(default 100, max 999\) |
| `filter` | string | No | OData filter expression \(e.g., "securityEnabled eq true"\) |
| `search` | string | No | Search string to filter groups by displayName or description |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `groups` | array | List of groups |
| `groupCount` | number | Number of groups returned |
### `microsoft_ad_get_group`
Get a group by ID from Azure AD (Microsoft Entra ID)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `groupId` | string | Yes | Group ID |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `group` | object | Group details |
| ↳ `id` | string | Group ID |
| ↳ `displayName` | string | Display name |
| ↳ `description` | string | Group description |
| ↳ `mail` | string | Email address |
| ↳ `mailEnabled` | boolean | Whether mail is enabled |
| ↳ `mailNickname` | string | Mail nickname |
| ↳ `securityEnabled` | boolean | Whether security is enabled |
| ↳ `groupTypes` | array | Group types |
| ↳ `visibility` | string | Group visibility |
| ↳ `createdDateTime` | string | Creation date |
### `microsoft_ad_create_group`
Create a new group in Azure AD (Microsoft Entra ID)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `displayName` | string | Yes | Display name for the group |
| `mailNickname` | string | Yes | Mail alias for the group \(ASCII only, max 64 characters\) |
| `description` | string | No | Group description |
| `mailEnabled` | boolean | Yes | Whether mail is enabled \(true for Microsoft 365 groups\) |
| `securityEnabled` | boolean | Yes | Whether security is enabled \(true for security groups\) |
| `groupTypes` | string | No | Group type: "Unified" for Microsoft 365 group, leave empty for security group |
| `visibility` | string | No | Group visibility: "Private" or "Public" |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `group` | object | Created group details |
| ↳ `id` | string | Group ID |
| ↳ `displayName` | string | Display name |
| ↳ `description` | string | Group description |
| ↳ `mail` | string | Email address |
| ↳ `mailEnabled` | boolean | Whether mail is enabled |
| ↳ `mailNickname` | string | Mail nickname |
| ↳ `securityEnabled` | boolean | Whether security is enabled |
| ↳ `groupTypes` | array | Group types |
| ↳ `visibility` | string | Group visibility |
| ↳ `createdDateTime` | string | Creation date |
### `microsoft_ad_update_group`
Update group properties in Azure AD (Microsoft Entra ID)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `groupId` | string | Yes | Group ID |
| `displayName` | string | No | Display name |
| `description` | string | No | Group description |
| `mailNickname` | string | No | Mail alias |
| `visibility` | string | No | Group visibility: "Private" or "Public" |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `updated` | boolean | Whether the update was successful |
| `groupId` | string | ID of the updated group |
### `microsoft_ad_delete_group`
Delete a group from Azure AD (Microsoft Entra ID). Microsoft 365 and security groups can be restored within 30 days.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `groupId` | string | Yes | Group ID |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `deleted` | boolean | Whether the deletion was successful |
| `groupId` | string | ID of the deleted group |
### `microsoft_ad_list_group_members`
List members of a group in Azure AD (Microsoft Entra ID)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `groupId` | string | Yes | Group ID |
| `top` | number | No | Maximum number of members to return \(default 100, max 999\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `members` | array | List of group members |
| `memberCount` | number | Number of members returned |
### `microsoft_ad_add_group_member`
Add a member to a group in Azure AD (Microsoft Entra ID)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `groupId` | string | Yes | Group ID |
| `memberId` | string | Yes | User ID of the member to add |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `added` | boolean | Whether the member was added successfully |
| `groupId` | string | Group ID |
| `memberId` | string | Member ID that was added |
### `microsoft_ad_remove_group_member`
Remove a member from a group in Azure AD (Microsoft Entra ID)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `groupId` | string | Yes | Group ID |
| `memberId` | string | Yes | User ID of the member to remove |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `removed` | boolean | Whether the member was removed successfully |
| `groupId` | string | Group ID |
| `memberId` | string | Member ID that was removed |

View File

@@ -0,0 +1,516 @@
---
title: Okta
description: Manage users and groups in Okta
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="okta"
color="#191919"
/>
{/* MANUAL-CONTENT-START:intro */}
[Okta](https://www.okta.com/) is an identity and access management platform that provides secure authentication, authorization, and user management for organizations.
With the Okta integration in Sim, you can:
- **List and search users**: Retrieve users from your Okta org with SCIM search expressions and filters
- **Manage user lifecycle**: Create, activate, deactivate, suspend, unsuspend, and delete users
- **Update user profiles**: Modify user attributes like name, email, phone, title, and department
- **Reset passwords**: Trigger password reset flows with optional email notification
- **Manage groups**: Create, update, delete, and list groups in your organization
- **Manage group membership**: Add or remove users from groups, and list group members
In Sim, the Okta integration enables your agents to automate identity management tasks as part of their workflows. This allows for scenarios such as onboarding new employees, offboarding departing users, managing group-based access, auditing user status, and responding to security events by suspending or deactivating accounts.
## Need Help?
If you encounter issues with the Okta integration, contact us at [help@sim.ai](mailto:help@sim.ai)
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate Okta identity management into your workflow. List, create, update, activate, suspend, and delete users. Reset passwords. Manage groups and group membership.
## Tools
### `okta_list_users`
List all users in your Okta organization with optional search and filtering
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Okta API token for authentication |
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
| `search` | string | No | Okta search expression \(e.g., profile.firstName eq "John" or profile.email co "example.com"\) |
| `filter` | string | No | Okta filter expression \(e.g., status eq "ACTIVE"\) |
| `limit` | number | No | Maximum number of users to return \(default: 200, max: 200\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `users` | array | Array of Okta user objects |
| ↳ `id` | string | User ID |
| ↳ `status` | string | User status \(ACTIVE, STAGED, PROVISIONED, etc.\) |
| ↳ `firstName` | string | First name |
| ↳ `lastName` | string | Last name |
| ↳ `email` | string | Email address |
| ↳ `login` | string | Login \(usually email\) |
| ↳ `mobilePhone` | string | Mobile phone |
| ↳ `title` | string | Job title |
| ↳ `department` | string | Department |
| ↳ `created` | string | Creation timestamp |
| ↳ `lastLogin` | string | Last login timestamp |
| ↳ `lastUpdated` | string | Last update timestamp |
| ↳ `activated` | string | Activation timestamp |
| ↳ `statusChanged` | string | Status change timestamp |
| `count` | number | Number of users returned |
| `success` | boolean | Operation success status |
### `okta_get_user`
Get a specific user by ID or login from your Okta organization
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Okta API token for authentication |
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
| `userId` | string | Yes | User ID or login \(email\) to look up |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | User ID |
| `status` | string | User status |
| `firstName` | string | First name |
| `lastName` | string | Last name |
| `email` | string | Email address |
| `login` | string | Login \(usually email\) |
| `mobilePhone` | string | Mobile phone |
| `secondEmail` | string | Secondary email |
| `displayName` | string | Display name |
| `title` | string | Job title |
| `department` | string | Department |
| `organization` | string | Organization |
| `manager` | string | Manager name |
| `managerId` | string | Manager ID |
| `division` | string | Division |
| `employeeNumber` | string | Employee number |
| `userType` | string | User type |
| `created` | string | Creation timestamp |
| `activated` | string | Activation timestamp |
| `lastLogin` | string | Last login timestamp |
| `lastUpdated` | string | Last update timestamp |
| `statusChanged` | string | Status change timestamp |
| `passwordChanged` | string | Password change timestamp |
| `success` | boolean | Operation success status |
### `okta_create_user`
Create a new user in your Okta organization
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Okta API token for authentication |
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
| `firstName` | string | Yes | First name of the user |
| `lastName` | string | Yes | Last name of the user |
| `email` | string | Yes | Email address of the user |
| `login` | string | No | Login for the user \(defaults to email if not provided\) |
| `password` | string | No | Password for the user \(if not set, user will be emailed to set password\) |
| `mobilePhone` | string | No | Mobile phone number |
| `title` | string | No | Job title |
| `department` | string | No | Department |
| `activate` | boolean | No | Whether to activate the user immediately \(default: true\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Created user ID |
| `status` | string | User status |
| `firstName` | string | First name |
| `lastName` | string | Last name |
| `email` | string | Email address |
| `login` | string | Login |
| `created` | string | Creation timestamp |
| `lastUpdated` | string | Last update timestamp |
| `success` | boolean | Operation success status |
### `okta_update_user`
Update a user profile in your Okta organization
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Okta API token for authentication |
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
| `userId` | string | Yes | User ID or login to update |
| `firstName` | string | No | Updated first name |
| `lastName` | string | No | Updated last name |
| `email` | string | No | Updated email address |
| `login` | string | No | Updated login |
| `mobilePhone` | string | No | Updated mobile phone number |
| `title` | string | No | Updated job title |
| `department` | string | No | Updated department |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | User ID |
| `status` | string | User status |
| `firstName` | string | First name |
| `lastName` | string | Last name |
| `email` | string | Email address |
| `login` | string | Login |
| `created` | string | Creation timestamp |
| `lastUpdated` | string | Last update timestamp |
| `success` | boolean | Operation success status |
### `okta_activate_user`
Activate a user in your Okta organization. Can only be performed on users with STAGED or DEPROVISIONED status. Optionally sends an activation email.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Okta API token for authentication |
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
| `userId` | string | Yes | User ID or login to activate |
| `sendEmail` | boolean | No | Send activation email to the user \(default: true\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `userId` | string | Activated user ID |
| `activated` | boolean | Whether the user was activated |
| `activationUrl` | string | Activation URL \(only returned when sendEmail is false\) |
| `activationToken` | string | Activation token \(only returned when sendEmail is false\) |
| `success` | boolean | Operation success status |
### `okta_deactivate_user`
Deactivate a user in your Okta organization. This transitions the user to DEPROVISIONED status.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Okta API token for authentication |
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
| `userId` | string | Yes | User ID or login to deactivate |
| `sendEmail` | boolean | No | Send deactivation email to admin \(default: false\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `userId` | string | Deactivated user ID |
| `deactivated` | boolean | Whether the user was deactivated |
| `success` | boolean | Operation success status |
### `okta_suspend_user`
Suspend a user in your Okta organization. Only users with ACTIVE status can be suspended. Suspended users cannot log in but retain group and app assignments.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Okta API token for authentication |
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
| `userId` | string | Yes | User ID or login to suspend |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `userId` | string | Suspended user ID |
| `suspended` | boolean | Whether the user was suspended |
| `success` | boolean | Operation success status |
### `okta_unsuspend_user`
Unsuspend a previously suspended user in your Okta organization. Returns the user to ACTIVE status.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Okta API token for authentication |
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
| `userId` | string | Yes | User ID or login to unsuspend |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `userId` | string | Unsuspended user ID |
| `unsuspended` | boolean | Whether the user was unsuspended |
| `success` | boolean | Operation success status |
### `okta_reset_password`
Generate a one-time token to reset a user password. Can email the reset link to the user or return it directly. Transitions the user to RECOVERY status.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Okta API token for authentication |
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
| `userId` | string | Yes | User ID or login to reset password for |
| `sendEmail` | boolean | No | Send password reset email to the user \(default: true\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `userId` | string | User ID |
| `resetPasswordUrl` | string | Password reset URL \(only returned when sendEmail is false\) |
| `success` | boolean | Operation success status |
### `okta_delete_user`
Permanently delete a user from your Okta organization. Can only be performed on DEPROVISIONED users. If the user is active, this will first deactivate them and a second call is needed to delete.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Okta API token for authentication |
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
| `userId` | string | Yes | User ID to delete |
| `sendEmail` | boolean | No | Send deactivation email to admin \(default: false\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `userId` | string | Deleted user ID |
| `deleted` | boolean | Whether the user was deleted |
| `success` | boolean | Operation success status |
### `okta_list_groups`
List all groups in your Okta organization with optional search and filtering
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Okta API token for authentication |
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
| `search` | string | No | Okta search expression for groups \(e.g., profile.name sw "Engineering" or type eq "OKTA_GROUP"\) |
| `filter` | string | No | Okta filter expression \(e.g., type eq "OKTA_GROUP"\) |
| `limit` | number | No | Maximum number of groups to return \(default: 10000, max: 10000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `groups` | array | Array of Okta group objects |
| ↳ `id` | string | Group ID |
| ↳ `name` | string | Group name |
| ↳ `description` | string | Group description |
| ↳ `type` | string | Group type \(OKTA_GROUP, APP_GROUP, BUILT_IN\) |
| ↳ `created` | string | Creation timestamp |
| ↳ `lastUpdated` | string | Last update timestamp |
| ↳ `lastMembershipUpdated` | string | Last membership change timestamp |
| `count` | number | Number of groups returned |
| `success` | boolean | Operation success status |
### `okta_get_group`
Get a specific group by ID from your Okta organization
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Okta API token for authentication |
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
| `groupId` | string | Yes | Group ID to look up |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Group ID |
| `name` | string | Group name |
| `description` | string | Group description |
| `type` | string | Group type |
| `created` | string | Creation timestamp |
| `lastUpdated` | string | Last update timestamp |
| `lastMembershipUpdated` | string | Last membership change timestamp |
| `success` | boolean | Operation success status |
### `okta_create_group`
Create a new group in your Okta organization
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Okta API token for authentication |
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
| `name` | string | Yes | Name of the group |
| `description` | string | No | Description of the group |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Created group ID |
| `name` | string | Group name |
| `description` | string | Group description |
| `type` | string | Group type |
| `created` | string | Creation timestamp |
| `lastUpdated` | string | Last update timestamp |
| `lastMembershipUpdated` | string | Last membership change timestamp |
| `success` | boolean | Operation success status |
### `okta_update_group`
Update a group profile in your Okta organization. Only groups of OKTA_GROUP type can be updated. All profile properties must be specified (full replacement).
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Okta API token for authentication |
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
| `groupId` | string | Yes | Group ID to update |
| `name` | string | Yes | Updated group name |
| `description` | string | No | Updated group description |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Group ID |
| `name` | string | Group name |
| `description` | string | Group description |
| `type` | string | Group type |
| `created` | string | Creation timestamp |
| `lastUpdated` | string | Last update timestamp |
| `lastMembershipUpdated` | string | Last membership change timestamp |
| `success` | boolean | Operation success status |
### `okta_delete_group`
Delete a group from your Okta organization. Groups of OKTA_GROUP or APP_GROUP type can be removed.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Okta API token for authentication |
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
| `groupId` | string | Yes | Group ID to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `groupId` | string | Deleted group ID |
| `deleted` | boolean | Whether the group was deleted |
| `success` | boolean | Operation success status |
### `okta_add_user_to_group`
Add a user to a group in your Okta organization
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Okta API token for authentication |
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
| `groupId` | string | Yes | Group ID to add the user to |
| `userId` | string | Yes | User ID to add to the group |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `groupId` | string | Group ID |
| `userId` | string | User ID added to the group |
| `added` | boolean | Whether the user was added |
| `success` | boolean | Operation success status |
### `okta_remove_user_from_group`
Remove a user from a group in your Okta organization
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Okta API token for authentication |
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
| `groupId` | string | Yes | Group ID to remove the user from |
| `userId` | string | Yes | User ID to remove from the group |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `groupId` | string | Group ID |
| `userId` | string | User ID removed from the group |
| `removed` | boolean | Whether the user was removed |
| `success` | boolean | Operation success status |
### `okta_list_group_members`
List all members of a specific group in your Okta organization
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Okta API token for authentication |
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
| `groupId` | string | Yes | Group ID to list members for |
| `limit` | number | No | Maximum number of members to return \(default: 1000, max: 1000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `members` | array | Array of group member user objects |
| ↳ `id` | string | User ID |
| ↳ `status` | string | User status |
| ↳ `firstName` | string | First name |
| ↳ `lastName` | string | Last name |
| ↳ `email` | string | Email address |
| ↳ `login` | string | Login |
| ↳ `mobilePhone` | string | Mobile phone |
| ↳ `title` | string | Job title |
| ↳ `department` | string | Department |
| ↳ `created` | string | Creation timestamp |
| ↳ `lastLogin` | string | Last login timestamp |
| ↳ `lastUpdated` | string | Last update timestamp |
| ↳ `activated` | string | Activation timestamp |
| ↳ `statusChanged` | string | Status change timestamp |
| `count` | number | Number of members returned |
| `success` | boolean | Operation success status |

View File

@@ -49,6 +49,9 @@ Generate completions using Perplexity AI chat models
| `max_tokens` | number | No | Maximum number of tokens to generate \(e.g., 1024, 2048, 4096\) |
| `temperature` | number | No | Sampling temperature between 0 and 1 \(e.g., 0.0 for deterministic, 0.7 for creative\) |
| `apiKey` | string | Yes | Perplexity API key |
| `pricing` | custom | No | No description |
| `metadata` | string | No | No description |
| `rateLimit` | string | No | No description |
#### Output
@@ -78,6 +81,8 @@ Get ranked search results from Perplexity
| `search_after_date` | string | No | Include only content published after this date \(format: MM/DD/YYYY\) |
| `search_before_date` | string | No | Include only content published before this date \(format: MM/DD/YYYY\) |
| `apiKey` | string | Yes | Perplexity API key |
| `pricing` | per_request | No | No description |
| `rateLimit` | string | No | No description |
#### Output

View File

@@ -47,6 +47,9 @@ A powerful web search tool that provides access to Google search results through
| `hl` | string | No | Language code for search results \(e.g., "en", "es", "de", "fr"\) |
| `type` | string | No | Type of search to perform \(e.g., "search", "news", "images", "videos", "places", "shopping"\) |
| `apiKey` | string | Yes | Serper API Key |
| `pricing` | custom | No | No description |
| `metadata` | string | No | No description |
| `rateLimit` | string | No | No description |
#### Output

View File

@@ -0,0 +1,262 @@
---
title: Workday
description: Manage workers, hiring, onboarding, and HR operations in Workday
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="workday"
color="#F5F0EB"
/>
## Usage Instructions
Integrate Workday HRIS into your workflow. Create pre-hires, hire employees, manage worker profiles, assign onboarding plans, handle job changes, retrieve compensation data, and process terminations.
## Tools
### `workday_get_worker`
Retrieve a specific worker profile including personal, employment, and organization data.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `tenantUrl` | string | Yes | Workday instance URL \(e.g., https://wd5-impl-services1.workday.com\) |
| `tenant` | string | Yes | Workday tenant name |
| `username` | string | Yes | Integration System User username |
| `password` | string | Yes | Integration System User password |
| `workerId` | string | Yes | Worker ID to retrieve \(e.g., 3aa5550b7fe348b98d7b5741afc65534\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `worker` | json | Worker profile with personal, employment, and organization data |
### `workday_list_workers`
List or search workers with optional filtering and pagination.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `tenantUrl` | string | Yes | Workday instance URL \(e.g., https://wd5-impl-services1.workday.com\) |
| `tenant` | string | Yes | Workday tenant name |
| `username` | string | Yes | Integration System User username |
| `password` | string | Yes | Integration System User password |
| `limit` | number | No | Maximum number of workers to return \(default: 20\) |
| `offset` | number | No | Number of records to skip for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `workers` | array | Array of worker profiles |
| `total` | number | Total number of matching workers |
### `workday_create_prehire`
Create a new pre-hire (applicant) record in Workday. This is typically the first step before hiring an employee.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `tenantUrl` | string | Yes | Workday instance URL \(e.g., https://wd5-impl-services1.workday.com\) |
| `tenant` | string | Yes | Workday tenant name |
| `username` | string | Yes | Integration System User username |
| `password` | string | Yes | Integration System User password |
| `legalName` | string | Yes | Full legal name of the pre-hire \(e.g., "Jane Doe"\) |
| `email` | string | No | Email address of the pre-hire |
| `phoneNumber` | string | No | Phone number of the pre-hire |
| `address` | string | No | Address of the pre-hire |
| `countryCode` | string | No | ISO 3166-1 Alpha-2 country code \(defaults to US\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `preHireId` | string | ID of the created pre-hire record |
| `descriptor` | string | Display name of the pre-hire |
### `workday_hire_employee`
Hire a pre-hire into an employee position. Converts an applicant into an active employee record with position, start date, and manager assignment.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `tenantUrl` | string | Yes | Workday instance URL \(e.g., https://wd5-impl-services1.workday.com\) |
| `tenant` | string | Yes | Workday tenant name |
| `username` | string | Yes | Integration System User username |
| `password` | string | Yes | Integration System User password |
| `preHireId` | string | Yes | Pre-hire \(applicant\) ID to convert into an employee |
| `positionId` | string | Yes | Position ID to assign the new hire to |
| `hireDate` | string | Yes | Hire date in ISO 8601 format \(e.g., 2025-06-01\) |
| `employeeType` | string | No | Employee type \(e.g., Regular, Temporary, Contractor\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `workerId` | string | Worker ID of the newly hired employee |
| `employeeId` | string | Employee ID assigned to the new hire |
| `eventId` | string | Event ID of the hire business process |
| `hireDate` | string | Effective hire date |
### `workday_update_worker`
Update fields on an existing worker record in Workday.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `tenantUrl` | string | Yes | Workday instance URL \(e.g., https://wd5-impl-services1.workday.com\) |
| `tenant` | string | Yes | Workday tenant name |
| `username` | string | Yes | Integration System User username |
| `password` | string | Yes | Integration System User password |
| `workerId` | string | Yes | Worker ID to update |
| `fields` | json | Yes | Fields to update as JSON \(e.g., \{"businessTitle": "Senior Engineer", "primaryWorkEmail": "new@company.com"\}\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `eventId` | string | Event ID of the change personal information business process |
| `workerId` | string | Worker ID that was updated |
### `workday_assign_onboarding`
Create or update an onboarding plan assignment for a worker. Sets up onboarding stages and manages the assignment lifecycle.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `tenantUrl` | string | Yes | Workday instance URL \(e.g., https://wd5-impl-services1.workday.com\) |
| `tenant` | string | Yes | Workday tenant name |
| `username` | string | Yes | Integration System User username |
| `password` | string | Yes | Integration System User password |
| `workerId` | string | Yes | Worker ID to assign the onboarding plan to |
| `onboardingPlanId` | string | Yes | Onboarding plan ID to assign |
| `actionEventId` | string | Yes | Action event ID that enables the onboarding plan \(e.g., the hiring event ID\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `assignmentId` | string | Onboarding plan assignment ID |
| `workerId` | string | Worker ID the plan was assigned to |
| `planId` | string | Onboarding plan ID that was assigned |
### `workday_get_organizations`
Retrieve organizations, departments, and cost centers from Workday.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `tenantUrl` | string | Yes | Workday instance URL \(e.g., https://wd5-impl-services1.workday.com\) |
| `tenant` | string | Yes | Workday tenant name |
| `username` | string | Yes | Integration System User username |
| `password` | string | Yes | Integration System User password |
| `type` | string | No | Organization type filter \(e.g., Supervisory, Cost_Center, Company, Region\) |
| `limit` | number | No | Maximum number of organizations to return \(default: 20\) |
| `offset` | number | No | Number of records to skip for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `organizations` | array | Array of organization records |
| `total` | number | Total number of matching organizations |
### `workday_change_job`
Perform a job change for a worker including transfers, promotions, demotions, and lateral moves.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `tenantUrl` | string | Yes | Workday instance URL \(e.g., https://wd5-impl-services1.workday.com\) |
| `tenant` | string | Yes | Workday tenant name |
| `username` | string | Yes | Integration System User username |
| `password` | string | Yes | Integration System User password |
| `workerId` | string | Yes | Worker ID for the job change |
| `effectiveDate` | string | Yes | Effective date for the job change in ISO 8601 format \(e.g., 2025-06-01\) |
| `newPositionId` | string | No | New position ID \(for transfers\) |
| `newJobProfileId` | string | No | New job profile ID \(for role changes\) |
| `newLocationId` | string | No | New work location ID \(for relocations\) |
| `newSupervisoryOrgId` | string | No | Target supervisory organization ID \(for org transfers\) |
| `reason` | string | Yes | Reason for the job change \(e.g., Promotion, Transfer, Reorganization\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `eventId` | string | Job change event ID |
| `workerId` | string | Worker ID the job change was applied to |
| `effectiveDate` | string | Effective date of the job change |
### `workday_get_compensation`
Retrieve compensation plan details for a specific worker.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `tenantUrl` | string | Yes | Workday instance URL \(e.g., https://wd5-impl-services1.workday.com\) |
| `tenant` | string | Yes | Workday tenant name |
| `username` | string | Yes | Integration System User username |
| `password` | string | Yes | Integration System User password |
| `workerId` | string | Yes | Worker ID to retrieve compensation data for |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `compensationPlans` | array | Array of compensation plan details |
| ↳ `id` | string | Compensation plan ID |
| ↳ `planName` | string | Name of the compensation plan |
| ↳ `amount` | number | Compensation amount |
| ↳ `currency` | string | Currency code |
| ↳ `frequency` | string | Pay frequency |
### `workday_terminate_worker`
Initiate a worker termination in Workday. Triggers the Terminate Employee business process.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `tenantUrl` | string | Yes | Workday instance URL \(e.g., https://wd5-impl-services1.workday.com\) |
| `tenant` | string | Yes | Workday tenant name |
| `username` | string | Yes | Integration System User username |
| `password` | string | Yes | Integration System User password |
| `workerId` | string | Yes | Worker ID to terminate |
| `terminationDate` | string | Yes | Termination date in ISO 8601 format \(e.g., 2025-06-01\) |
| `reason` | string | Yes | Termination reason \(e.g., Resignation, End_of_Contract, Retirement\) |
| `notificationDate` | string | No | Date the termination was communicated in ISO 8601 format |
| `lastDayOfWork` | string | No | Last day of work in ISO 8601 format \(defaults to termination date\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `eventId` | string | Termination event ID |
| `workerId` | string | Worker ID that was terminated |
| `terminationDate` | string | Effective termination date |

View File

@@ -1,6 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Eye, EyeOff } from 'lucide-react'
import Link from 'next/link'
@@ -16,6 +16,7 @@ import {
} from '@/components/emcn'
import { client } from '@/lib/auth/auth-client'
import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
@@ -53,24 +54,6 @@ const PASSWORD_VALIDATIONS = {
},
}
const validateCallbackUrl = (url: string): boolean => {
try {
if (url.startsWith('/')) {
return true
}
const currentOrigin = typeof window !== 'undefined' ? window.location.origin : ''
if (url.startsWith(currentOrigin)) {
return true
}
return false
} catch (error) {
logger.error('Error validating callback URL:', { error, url })
return false
}
}
const validatePassword = (passwordValue: string): string[] => {
const errors: string[] = []
@@ -99,15 +82,21 @@ export default function LoginPage({
const router = useRouter()
const searchParams = useSearchParams()
const [isLoading, setIsLoading] = useState(false)
const [_mounted, setMounted] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [password, setPassword] = useState('')
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
const [showValidationError, setShowValidationError] = useState(false)
const buttonClass = useBrandedButtonClass()
const [callbackUrl, setCallbackUrl] = useState('/workspace')
const [isInviteFlow, setIsInviteFlow] = useState(false)
const callbackUrlParam = searchParams?.get('callbackUrl')
const isValidCallbackUrl = callbackUrlParam ? validateCallbackUrl(callbackUrlParam) : false
const invalidCallbackRef = useRef(false)
if (callbackUrlParam && !isValidCallbackUrl && !invalidCallbackRef.current) {
invalidCallbackRef.current = true
logger.warn('Invalid callback URL detected and blocked:', { url: callbackUrlParam })
}
const callbackUrl = isValidCallbackUrl ? callbackUrlParam! : '/workspace'
const isInviteFlow = searchParams?.get('invite_flow') === 'true'
const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false)
const [forgotPasswordEmail, setForgotPasswordEmail] = useState('')
@@ -120,30 +109,11 @@ export default function LoginPage({
const [email, setEmail] = useState('')
const [emailErrors, setEmailErrors] = useState<string[]>([])
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
const [resetSuccessMessage, setResetSuccessMessage] = useState<string | null>(null)
useEffect(() => {
setMounted(true)
if (searchParams) {
const callback = searchParams.get('callbackUrl')
if (callback) {
if (validateCallbackUrl(callback)) {
setCallbackUrl(callback)
} else {
logger.warn('Invalid callback URL detected and blocked:', { url: callback })
}
}
const inviteFlow = searchParams.get('invite_flow') === 'true'
setIsInviteFlow(inviteFlow)
const resetSuccess = searchParams.get('resetSuccess') === 'true'
if (resetSuccess) {
setResetSuccessMessage('Password reset successful. Please sign in with your new password.')
}
}
}, [searchParams])
const [resetSuccessMessage, setResetSuccessMessage] = useState<string | null>(() =>
searchParams?.get('resetSuccess') === 'true'
? 'Password reset successful. Please sign in with your new password.'
: null
)
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -205,7 +175,7 @@ export default function LoginPage({
}
try {
const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : '/workspace'
const safeCallbackUrl = callbackUrl
let errorHandled = false
const result = await client.signIn.email(

View File

@@ -1,6 +1,6 @@
'use client'
import { Suspense, useEffect, useState } from 'react'
import { Suspense, useState } from 'react'
import { createLogger } from '@sim/logger'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
@@ -22,14 +22,9 @@ function ResetPasswordContent() {
text: '',
})
useEffect(() => {
if (!token) {
setStatusMessage({
type: 'error',
text: 'Invalid or missing reset token. Please request a new password reset link.',
})
}
}, [token])
const tokenError = !token
? 'Invalid or missing reset token. Please request a new password reset link.'
: null
const handleResetPassword = async (password: string) => {
try {
@@ -87,8 +82,8 @@ function ResetPasswordContent() {
token={token}
onSubmit={handleResetPassword}
isSubmitting={isSubmitting}
statusType={statusMessage.type}
statusMessage={statusMessage.text}
statusType={tokenError ? 'error' : statusMessage.type}
statusMessage={tokenError ?? statusMessage.text}
/>
</div>

View File

@@ -1,6 +1,6 @@
'use client'
import { Suspense, useEffect, useState } from 'react'
import { Suspense, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Eye, EyeOff } from 'lucide-react'
import Link from 'next/link'
@@ -82,49 +82,32 @@ function SignupFormContent({
const searchParams = useSearchParams()
const { refetch: refetchSession } = useSession()
const [isLoading, setIsLoading] = useState(false)
const [, setMounted] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [password, setPassword] = useState('')
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
const [showValidationError, setShowValidationError] = useState(false)
const [email, setEmail] = useState('')
const [email, setEmail] = useState(() => searchParams.get('email') ?? '')
const [emailError, setEmailError] = useState('')
const [emailErrors, setEmailErrors] = useState<string[]>([])
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
const [redirectUrl, setRedirectUrl] = useState('')
const [isInviteFlow, setIsInviteFlow] = useState(false)
const buttonClass = useBrandedButtonClass()
const redirectUrl = useMemo(
() => searchParams.get('redirect') || searchParams.get('callbackUrl') || '',
[searchParams]
)
const isInviteFlow = useMemo(
() =>
searchParams.get('invite_flow') === 'true' ||
redirectUrl.startsWith('/invite/') ||
redirectUrl.startsWith('/credential-account/'),
[searchParams, redirectUrl]
)
const [name, setName] = useState('')
const [nameErrors, setNameErrors] = useState<string[]>([])
const [showNameValidationError, setShowNameValidationError] = useState(false)
useEffect(() => {
setMounted(true)
const emailParam = searchParams.get('email')
if (emailParam) {
setEmail(emailParam)
}
// Check both 'redirect' and 'callbackUrl' params (login page uses callbackUrl)
const redirectParam = searchParams.get('redirect') || searchParams.get('callbackUrl')
if (redirectParam) {
setRedirectUrl(redirectParam)
if (
redirectParam.startsWith('/invite/') ||
redirectParam.startsWith('/credential-account/')
) {
setIsInviteFlow(true)
}
}
const inviteFlowParam = searchParams.get('invite_flow')
if (inviteFlowParam === 'true') {
setIsInviteFlow(true)
}
}, [searchParams])
const validatePassword = (passwordValue: string): string[] => {
const errors: string[] = []

View File

@@ -303,7 +303,7 @@ export default function Collaboration() {
</div>
<Link
href='/studio/multiplayer'
href='/blog/multiplayer'
target='_blank'
rel='noopener noreferrer'
className='relative mx-4 mb-6 flex cursor-none items-center gap-[14px] rounded-[5px] border border-[#2A2A2A] bg-[#1C1C1C] px-[12px] py-[10px] transition-colors hover:border-[#3d3d3d] hover:bg-[#232323] sm:mx-8 md:absolute md:bottom-10 md:left-[80px] md:z-20 md:mx-0 md:mb-0'

View File

@@ -4,14 +4,639 @@
* SEO:
* - `<section id="enterprise" aria-labelledby="enterprise-heading">`.
* - `<h2 id="enterprise-heading">` for the section title.
* - Compliance certs (SOC2, HIPAA) as visible `<strong>` text.
* - Compliance certs (SOC 2, HIPAA) as visible `<strong>` text.
* - Enterprise CTA links to contact form via `<a>` with `rel="noopener noreferrer"`.
*
* GEO:
* - Entity-rich: "Sim is SOC2 and HIPAA compliant" — not "We are compliant."
* - Entity-rich: "Sim is SOC 2 and HIPAA compliant" — not "We are compliant."
* - `<ul>` checklist of features (SSO, RBAC, audit logs, SLA, on-premise deployment)
* as an atomic answer block for "What enterprise features does Sim offer?".
*/
export default function Enterprise() {
return null
'use client'
import { useEffect, useRef, useState } from 'react'
import { AnimatePresence, motion, useInView } from 'framer-motion'
import Image from 'next/image'
import Link from 'next/link'
import { Badge, ChevronDown } from '@/components/emcn'
import { Lock } from '@/components/emcn/icons'
import { GithubIcon } from '@/components/icons'
import { PROVIDER_DEFINITIONS } from '@/providers/models'
/** Consistent color per actor — same pattern as Collaboration section cursors. */
const ACTOR_COLORS: Record<string, string> = {
'Sarah K.': '#2ABBF8',
'Sid G.': '#33C482',
'Theo L.': '#FA4EDF',
'Abhay K.': '#FFCC02',
'Danny S.': '#FF6B35',
}
/** Left accent bar opacity by recency — newest is brightest. */
const ACCENT_OPACITIES = [0.75, 0.5, 0.35, 0.22, 0.12, 0.05] as const
interface LogEntry {
id: number
actor: string
/** Matches the `description` field stored by recordAudit() */
description: string
resourceType: string
/** Unix ms timestamp of when this entry was "received" */
insertedAt: number
}
function formatTimeAgo(insertedAt: number): string {
const elapsed = Date.now() - insertedAt
if (elapsed < 8_000) return 'just now'
if (elapsed < 60_000) return `${Math.floor(elapsed / 1000)}s ago`
return `${Math.floor(elapsed / 60_000)}m ago`
}
/**
* Entry templates using real description strings from the actual recordAudit()
* calls across the codebase (e.g. `Added BYOK key for openai`,
* `Invited alex@acme.com to workspace as member`).
*/
const ENTRY_TEMPLATES: Omit<LogEntry, 'id' | 'insertedAt'>[] = [
{ actor: 'Sarah K.', description: 'Deployed workflow "Email Triage"', resourceType: 'workflow' },
{
actor: 'Sid G.',
description: 'Invited alex@acme.com to workspace as member',
resourceType: 'member',
},
{ actor: 'Theo L.', description: 'Added BYOK key for openai', resourceType: 'byok_key' },
{ actor: 'Sarah K.', description: 'Created workflow "Invoice Parser"', resourceType: 'workflow' },
{
actor: 'Abhay K.',
description: 'Created permission group "Engineering"',
resourceType: 'permission_group',
},
{ actor: 'Danny S.', description: 'Created API key "Production Key"', resourceType: 'api_key' },
{
actor: 'Theo L.',
description: 'Changed permissions for sam@acme.com to editor',
resourceType: 'member',
},
{ actor: 'Sarah K.', description: 'Uploaded file "Q3_Report.pdf"', resourceType: 'file' },
{
actor: 'Sid G.',
description: 'Created credential set "Prod Keys"',
resourceType: 'credential_set',
},
{
actor: 'Abhay K.',
description: 'Created knowledge base "Internal Docs"',
resourceType: 'knowledge_base',
},
{ actor: 'Danny S.', description: 'Updated environment variables', resourceType: 'environment' },
{
actor: 'Sarah K.',
description: 'Added tool "search_web" to MCP server',
resourceType: 'mcp_server',
},
{ actor: 'Sid G.', description: 'Created webhook "Stripe Payment"', resourceType: 'webhook' },
{ actor: 'Theo L.', description: 'Deployed chat "Support Assistant"', resourceType: 'chat' },
{ actor: 'Abhay K.', description: 'Created table "Lead Tracker"', resourceType: 'table' },
{ actor: 'Danny S.', description: 'Revoked API key "Staging Key"', resourceType: 'api_key' },
{
actor: 'Sarah K.',
description: 'Duplicated workflow "Data Enrichment"',
resourceType: 'workflow',
},
{
actor: 'Sid G.',
description: 'Removed member theo@acme.com from workspace',
resourceType: 'member',
},
{
actor: 'Theo L.',
description: 'Updated knowledge base "Product Docs"',
resourceType: 'knowledge_base',
},
{ actor: 'Abhay K.', description: 'Created folder "Finance Workflows"', resourceType: 'folder' },
{
actor: 'Danny S.',
description: 'Uploaded document "onboarding-guide.pdf"',
resourceType: 'document',
},
{
actor: 'Sarah K.',
description: 'Updated credential set "Prod Keys"',
resourceType: 'credential_set',
},
{
actor: 'Sid G.',
description: 'Added member abhay@acme.com to permission group "Engineering"',
resourceType: 'permission_group',
},
{ actor: 'Theo L.', description: 'Locked workflow "Customer Sync"', resourceType: 'workflow' },
]
const INITIAL_OFFSETS_MS = [0, 20_000, 75_000, 180_000, 360_000, 600_000]
const MARQUEE_KEYFRAMES = `
@keyframes marquee {
0% { transform: translateX(0); }
100% { transform: translateX(-25%); }
}
@media (prefers-reduced-motion: reduce) {
@keyframes marquee { 0%, 100% { transform: none; } }
}
`
const FEATURE_TAGS = [
'Access Control',
'Self-Hosting',
'Bring Your Own Key',
'Credential Sharing',
'Custom Limits',
'Admin API',
'White Labeling',
'Dedicated Support',
'99.99% Uptime SLA',
'Workflow Versioning',
'On-Premise',
'Organizations',
'Workspace Export',
'Audit Logs',
] as const
interface AuditRowProps {
entry: LogEntry
index: number
}
function AuditRow({ entry, index }: AuditRowProps) {
const color = ACTOR_COLORS[entry.actor] ?? '#F6F6F6'
const accentOpacity = ACCENT_OPACITIES[index] ?? 0.04
const timeAgo = formatTimeAgo(entry.insertedAt)
return (
<div className='group relative overflow-hidden border-[#2A2A2A] border-b bg-[#1C1C1C] transition-colors duration-150 last:border-b-0 hover:bg-[#212121]'>
{/* Left accent bar — brightness encodes recency */}
<div
aria-hidden='true'
className='absolute top-0 bottom-0 left-0 w-[2px] transition-opacity duration-150 group-hover:opacity-100'
style={{ backgroundColor: color, opacity: accentOpacity }}
/>
{/* Row content */}
<div className='flex min-w-0 items-center gap-3 py-[10px] pr-4 pl-5'>
{/* Actor avatar */}
<div
className='flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full'
style={{ backgroundColor: `${color}20` }}
>
<span className='font-[500] font-season text-[9px] leading-none' style={{ color }}>
{entry.actor[0]}
</span>
</div>
{/* Time */}
<span className='w-[56px] shrink-0 font-[430] font-season text-[#F6F6F6]/30 text-[11px] leading-none tracking-[0.02em]'>
{timeAgo}
</span>
<span className='min-w-0 truncate font-[430] font-season text-[12px] leading-none tracking-[0.02em]'>
<span className='text-[#F6F6F6]/80'>{entry.actor}</span>
<span className='hidden sm:inline'>
<span className='text-[#F6F6F6]/40'> · </span>
<span className='text-[#F6F6F6]/55'>{entry.description}</span>
</span>
</span>
</div>
</div>
)
}
function AuditLogPreview() {
const counterRef = useRef(ENTRY_TEMPLATES.length)
const templateIndexRef = useRef(6 % ENTRY_TEMPLATES.length)
const now = Date.now()
const [entries, setEntries] = useState<LogEntry[]>(() =>
ENTRY_TEMPLATES.slice(0, 6).map((t, i) => ({
...t,
id: i,
insertedAt: now - INITIAL_OFFSETS_MS[i],
}))
)
const [, tick] = useState(0)
useEffect(() => {
const addInterval = setInterval(() => {
const template = ENTRY_TEMPLATES[templateIndexRef.current]
templateIndexRef.current = (templateIndexRef.current + 1) % ENTRY_TEMPLATES.length
setEntries((prev) => [
{ ...template, id: counterRef.current++, insertedAt: Date.now() },
...prev.slice(0, 5),
])
}, 2600)
// Refresh time labels every 5s so "just now" ages to "Xs ago"
const tickInterval = setInterval(() => tick((n) => n + 1), 5_000)
return () => {
clearInterval(addInterval)
clearInterval(tickInterval)
}
}, [])
return (
<div className='mt-5 overflow-hidden px-6 md:mt-6 md:px-8'>
<AnimatePresence mode='popLayout' initial={false}>
{entries.map((entry, index) => (
<motion.div
key={entry.id}
layout
initial={{ y: -48, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
layout: {
type: 'spring',
stiffness: 380,
damping: 38,
mass: 0.8,
},
y: { duration: 0.32, ease: [0.25, 0.46, 0.45, 0.94] },
opacity: { duration: 0.25 },
}}
>
<AuditRow entry={entry} index={index} />
</motion.div>
))}
</AnimatePresence>
</div>
)
}
interface PermissionFeature {
name: string
key: string
defaultEnabled: boolean
providerId?: string
}
interface PermissionCategory {
label: string
color: string
features: PermissionFeature[]
}
const PERMISSION_CATEGORIES: PermissionCategory[] = [
{
label: 'Providers',
color: '#FA4EDF',
features: [
{ key: 'openai', name: 'OpenAI', defaultEnabled: true, providerId: 'openai' },
{ key: 'anthropic', name: 'Anthropic', defaultEnabled: true, providerId: 'anthropic' },
{ key: 'google', name: 'Google', defaultEnabled: false, providerId: 'google' },
{ key: 'xai', name: 'xAI', defaultEnabled: true, providerId: 'xai' },
],
},
{
label: 'Workspace',
color: '#2ABBF8',
features: [
{ key: 'knowledge-base', name: 'Knowledge Base', defaultEnabled: true },
{ key: 'tables', name: 'Tables', defaultEnabled: true },
{ key: 'copilot', name: 'Copilot', defaultEnabled: false },
{ key: 'environment', name: 'Environment', defaultEnabled: false },
],
},
{
label: 'Tools',
color: '#33C482',
features: [
{ key: 'mcp-tools', name: 'MCP Tools', defaultEnabled: true },
{ key: 'custom-tools', name: 'Custom Tools', defaultEnabled: false },
{ key: 'skills', name: 'Skills', defaultEnabled: true },
{ key: 'invitations', name: 'Invitations', defaultEnabled: true },
],
},
]
const INITIAL_ACCESS_STATE = Object.fromEntries(
PERMISSION_CATEGORIES.flatMap((category) =>
category.features.map((feature) => [feature.key, feature.defaultEnabled])
)
)
function CheckboxIcon({ checked, color }: { checked: boolean; color: string }) {
return (
<div
className='h-[6px] w-[6px] shrink-0 rounded-full transition-colors duration-200'
style={{
backgroundColor: checked ? color : 'transparent',
border: checked ? 'none' : '1.5px solid #3A3A3A',
}}
/>
)
}
function ProviderPreviewIcon({ providerId }: { providerId?: string }) {
if (!providerId) return null
const ProviderIcon = PROVIDER_DEFINITIONS[providerId]?.icon
if (!ProviderIcon) return null
return (
<div className='relative flex h-[14px] w-[14px] shrink-0 items-center justify-center opacity-50 brightness-0 invert'>
<ProviderIcon className='!h-[14px] !w-[14px]' />
</div>
)
}
function AccessControlPanel() {
const ref = useRef(null)
const isInView = useInView(ref, { once: true, margin: '-40px' })
const [accessState, setAccessState] = useState<Record<string, boolean>>(INITIAL_ACCESS_STATE)
return (
<div ref={ref}>
<div className='lg:hidden'>
{PERMISSION_CATEGORIES.map((category, catIdx) => {
const offsetBefore = PERMISSION_CATEGORIES.slice(0, catIdx).reduce(
(sum, c) => sum + c.features.length,
0
)
return (
<div key={category.label} className={catIdx > 0 ? 'mt-4' : ''}>
<span className='font-[430] font-season text-[#F6F6F6]/30 text-[10px] uppercase leading-none tracking-[0.08em]'>
{category.label}
</span>
<div className='mt-[8px] grid grid-cols-2 gap-x-4 gap-y-[8px]'>
{category.features.map((feature, featIdx) => {
const enabled = accessState[feature.key]
return (
<motion.div
key={feature.key}
className='flex cursor-pointer items-center gap-[8px] rounded-[4px] py-[2px]'
initial={{ opacity: 0, x: -6 }}
animate={isInView ? { opacity: 1, x: 0 } : {}}
transition={{
delay: 0.05 + (offsetBefore + featIdx) * 0.04,
duration: 0.3,
}}
onClick={() =>
setAccessState((prev) => ({ ...prev, [feature.key]: !prev[feature.key] }))
}
whileTap={{ scale: 0.98 }}
>
<CheckboxIcon checked={enabled} color={category.color} />
<ProviderPreviewIcon providerId={feature.providerId} />
<span
className='truncate font-[430] font-season text-[13px] leading-none tracking-[0.02em]'
style={{ color: enabled ? '#F6F6F6AA' : '#F6F6F640' }}
>
{feature.name}
</span>
</motion.div>
)
})}
</div>
</div>
)
})}
</div>
{/* Desktop — categorized grid */}
<div className='hidden lg:block'>
{PERMISSION_CATEGORIES.map((category, catIdx) => (
<div key={category.label} className={catIdx > 0 ? 'mt-4' : ''}>
<span className='font-[430] font-season text-[#F6F6F6]/30 text-[10px] uppercase leading-none tracking-[0.08em]'>
{category.label}
</span>
<div className='mt-[8px] grid grid-cols-2 gap-x-4 gap-y-[8px]'>
{category.features.map((feature, featIdx) => {
const enabled = accessState[feature.key]
const currentIndex =
PERMISSION_CATEGORIES.slice(0, catIdx).reduce(
(sum, c) => sum + c.features.length,
0
) + featIdx
return (
<motion.div
key={feature.key}
className='flex cursor-pointer items-center gap-[8px] rounded-[4px] py-[2px]'
initial={{ opacity: 0, x: -6 }}
animate={isInView ? { opacity: 1, x: 0 } : {}}
transition={{
delay: 0.1 + currentIndex * 0.04,
duration: 0.3,
ease: [0.25, 0.46, 0.45, 0.94],
}}
onClick={() =>
setAccessState((prev) => ({ ...prev, [feature.key]: !prev[feature.key] }))
}
whileTap={{ scale: 0.98 }}
>
<CheckboxIcon checked={enabled} color={category.color} />
<ProviderPreviewIcon providerId={feature.providerId} />
<span
className='truncate font-[430] font-season text-[11px] leading-none tracking-[0.02em] transition-opacity duration-200'
style={{ color: enabled ? '#F6F6F6AA' : '#F6F6F640' }}
>
{feature.name}
</span>
</motion.div>
)
})}
</div>
</div>
))}
</div>
</div>
)
}
function TrustStrip() {
return (
<div className='mx-6 mt-4 grid grid-cols-1 overflow-hidden rounded-[8px] border border-[#2A2A2A] sm:grid-cols-3 md:mx-8'>
{/* SOC 2 + HIPAA combined */}
<Link
href='https://trust.delve.co/sim-studio'
target='_blank'
rel='noopener noreferrer'
className='group flex items-center gap-3 border-[#2A2A2A] border-b px-4 py-[14px] transition-colors hover:bg-[#212121] sm:border-r sm:border-b-0'
>
<Image
src='/footer/soc2.png'
alt='SOC 2 Type II'
width={22}
height={22}
className='shrink-0 object-contain'
/>
<div className='flex flex-col gap-[3px]'>
<strong className='font-[430] font-season text-[13px] text-white leading-none'>
SOC 2 & HIPAA
</strong>
<span className='font-[430] font-season text-[#F6F6F6]/30 text-[11px] leading-none tracking-[0.02em] transition-colors group-hover:text-[#F6F6F6]/55'>
Type II · PHI protected
</span>
</div>
</Link>
{/* Open Source — center */}
<Link
href='https://github.com/simstudioai/sim'
target='_blank'
rel='noopener noreferrer'
className='group flex items-center gap-3 border-[#2A2A2A] border-b px-4 py-[14px] transition-colors hover:bg-[#212121] sm:border-r sm:border-b-0'
>
<div className='flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full bg-[#FFCC02]/10'>
<GithubIcon width={11} height={11} className='text-[#FFCC02]/75' />
</div>
<div className='flex flex-col gap-[3px]'>
<strong className='font-[430] font-season text-[13px] text-white leading-none'>
Open Source
</strong>
<span className='font-[430] font-season text-[#F6F6F6]/30 text-[11px] leading-none tracking-[0.02em] transition-colors group-hover:text-[#F6F6F6]/55'>
View on GitHub
</span>
</div>
</Link>
{/* SSO */}
<div className='flex items-center gap-3 px-4 py-[14px]'>
<div className='flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full bg-[#2ABBF8]/10'>
<Lock className='h-[14px] w-[14px] text-[#2ABBF8]/75' />
</div>
<div className='flex flex-col gap-[3px]'>
<strong className='font-[430] font-season text-[13px] text-white leading-none'>
SSO & SCIM
</strong>
<span className='font-[430] font-season text-[#F6F6F6]/30 text-[11px] leading-none tracking-[0.02em]'>
Okta, Azure AD, Google
</span>
</div>
</div>
</div>
)
}
export default function Enterprise() {
return (
<section id='enterprise' aria-labelledby='enterprise-heading' className='bg-[#F6F6F6]'>
<div className='px-4 pt-[60px] pb-[40px] sm:px-8 sm:pt-[80px] sm:pb-0 md:px-[80px] md:pt-[100px]'>
<div className='flex flex-col items-start gap-3 sm:gap-4 md:gap-[20px]'>
<Badge
variant='blue'
size='md'
dot
className='bg-[#FFCC02]/10 font-season text-[#FFCC02] uppercase tracking-[0.02em]'
>
Enterprise
</Badge>
<h2
id='enterprise-heading'
className='max-w-[600px] font-[430] font-season text-[#1C1C1C] text-[32px] leading-[100%] tracking-[-0.02em] sm:text-[36px] md:text-[40px]'
>
Enterprise features for
<br />
fast, scalable workflows
</h2>
</div>
<div className='mt-8 overflow-hidden rounded-[12px] bg-[#1C1C1C] sm:mt-10 md:mt-12'>
<div className='grid grid-cols-1 border-[#2A2A2A] border-b lg:grid-cols-[1fr_420px]'>
{/* Audit Trail */}
<div className='border-[#2A2A2A] lg:border-r'>
<div className='px-6 pt-6 md:px-8 md:pt-8'>
<h3 className='font-[430] font-season text-[16px] text-white leading-[120%] tracking-[-0.01em]'>
Audit Trail
</h3>
<p className='mt-2 max-w-[480px] font-[430] font-season text-[#F6F6F6]/50 text-[14px] leading-[150%] tracking-[0.02em]'>
Every action is captured with full actor attribution.
</p>
</div>
<AuditLogPreview />
<div className='h-6 md:h-8' />
</div>
{/* Access Control */}
<div className='border-[#2A2A2A] border-t lg:border-t-0'>
<div className='px-6 pt-6 md:px-8 md:pt-8'>
<h3 className='font-[430] font-season text-[16px] text-white leading-[120%] tracking-[-0.01em]'>
Access Control
</h3>
<p className='mt-[6px] font-[430] font-season text-[#F6F6F6]/50 text-[14px] leading-[150%] tracking-[0.02em]'>
Restrict providers, surfaces, and tools per group.
</p>
</div>
<div className='mt-5 px-6 pb-6 md:mt-6 md:px-8 md:pb-8'>
<AccessControlPanel />
</div>
</div>
</div>
<TrustStrip />
{/* Scrolling feature ticker */}
<div className='relative mt-6 overflow-hidden border-[#2A2A2A] border-t'>
<style dangerouslySetInnerHTML={{ __html: MARQUEE_KEYFRAMES }} />
{/* Fade edges */}
<div
aria-hidden='true'
className='pointer-events-none absolute top-0 bottom-0 left-0 z-10 w-16'
style={{ background: 'linear-gradient(to right, #1C1C1C, transparent)' }}
/>
<div
aria-hidden='true'
className='pointer-events-none absolute top-0 right-0 bottom-0 z-10 w-16'
style={{ background: 'linear-gradient(to left, #1C1C1C, transparent)' }}
/>
{/* Duplicate tags for seamless loop */}
<div className='flex w-max' style={{ animation: 'marquee 30s linear infinite' }}>
{[...FEATURE_TAGS, ...FEATURE_TAGS, ...FEATURE_TAGS, ...FEATURE_TAGS].map(
(tag, i) => (
<span
key={i}
className='whitespace-nowrap border-[#2A2A2A] border-r px-5 py-4 font-[430] font-season text-[#F6F6F6]/40 text-[13px] leading-none tracking-[0.02em]'
>
{tag}
</span>
)
)}
</div>
</div>
<div className='flex items-center justify-between border-[#2A2A2A] border-t px-6 py-5 md:px-8 md:py-6'>
<p className='font-[430] font-season text-[#F6F6F6]/40 text-[15px] leading-[150%] tracking-[0.02em]'>
Ready for growth?
</p>
<Link
href='/contact'
className='group/cta inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-white bg-white px-[10px] font-[430] font-season text-[14px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
>
Book a demo
<span className='relative h-[10px] w-[10px] shrink-0'>
<ChevronDown className='-rotate-90 absolute inset-0 h-[10px] w-[10px] transition-opacity duration-150 group-hover/cta:opacity-0' />
<svg
className='absolute inset-0 h-[10px] w-[10px] opacity-0 transition-opacity duration-150 group-hover/cta:opacity-100'
viewBox='0 0 10 10'
fill='none'
>
<path
d='M1 5H8M5.5 2L8.5 5L5.5 8'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
strokeLinejoin='miter'
fill='none'
/>
</svg>
</span>
</Link>
</div>
</div>
</div>
</section>
)
}

View File

@@ -2,6 +2,8 @@
import { type SVGProps, useEffect, useRef, useState } from 'react'
import { AnimatePresence, motion, useInView } from 'framer-motion'
import ReactMarkdown, { type Components } from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { ChevronDown } from '@/components/emcn'
import { Database, File, Library, Table } from '@/components/emcn/icons'
import {
@@ -16,6 +18,7 @@ import {
xAIIcon,
} from '@/components/icons'
import { CsvIcon, JsonIcon, MarkdownIcon, PdfIcon } from '@/components/icons/document-icons'
import { cn } from '@/lib/core/utils/cn'
interface FeaturesPreviewProps {
activeTab: number
@@ -124,7 +127,7 @@ const EXPAND_TARGETS: Record<number, { row: number; col: number }> = {
}
const EXPAND_ROW_COUNTS: Record<number, number> = {
1: 10,
1: 8,
2: 10,
3: 10,
4: 7,
@@ -603,7 +606,28 @@ const MOCK_KB_DATA = [
['metrics.csv', '1.4 MB', '5.8k', '38', 'enabled'],
] as const
const MD_COMPONENTS: Components = {
h1: ({ children }) => (
<h1 className='mb-4 border-[#E5E5E5] border-b pb-2 font-semibold text-[#1C1C1C] text-[20px]'>
{children}
</h1>
),
h2: ({ children }) => (
<h2 className='mt-5 mb-3 border-[#E5E5E5] border-b pb-1.5 font-semibold text-[#1C1C1C] text-[16px]'>
{children}
</h2>
),
ul: ({ children }) => <ul className='mb-3 list-disc pl-[24px]'>{children}</ul>,
ol: ({ children }) => <ol className='mb-3 list-decimal pl-[24px]'>{children}</ol>,
li: ({ children }) => (
<li className='mb-1 text-[#1C1C1C] text-[14px] leading-[1.6]'>{children}</li>
),
p: ({ children }) => <p className='mb-3 text-[#1C1C1C] text-[14px] leading-[1.6]'>{children}</p>,
}
function MockFullFiles() {
const [source, setSource] = useState(MOCK_MD_SOURCE)
return (
<div className='flex h-full flex-col'>
<div className='flex h-[44px] shrink-0 items-center border-[#E5E5E5] border-b px-[24px]'>
@@ -622,9 +646,13 @@ function MockFullFiles() {
animate={{ opacity: 1 }}
transition={{ duration: 0.4, delay: 0.3 }}
>
<pre className='h-full overflow-auto whitespace-pre-wrap p-[24px] font-[300] font-mono text-[#1C1C1C] text-[12px] leading-[1.7]'>
{MOCK_MD_SOURCE}
</pre>
<textarea
value={source}
onChange={(e) => setSource(e.target.value)}
spellCheck={false}
autoCorrect='off'
className='h-full w-full resize-none overflow-auto whitespace-pre-wrap bg-transparent p-[24px] font-[300] font-mono text-[#1C1C1C] text-[12px] leading-[1.7] outline-none'
/>
</motion.div>
<div className='h-full w-px shrink-0 bg-[#E5E5E5]' />
@@ -636,47 +664,9 @@ function MockFullFiles() {
transition={{ duration: 0.4, delay: 0.5 }}
>
<div className='h-full overflow-auto p-[24px]'>
<h1 className='mb-4 border-[#E5E5E5] border-b pb-2 font-semibold text-[#1C1C1C] text-[20px]'>
Meeting Notes
</h1>
<h2 className='mt-5 mb-3 border-[#E5E5E5] border-b pb-1.5 font-semibold text-[#1C1C1C] text-[16px]'>
Action Items
</h2>
<ul className='mb-3 list-disc pl-[24px]'>
<li className='mb-1 text-[#1C1C1C] text-[14px] leading-[1.6]'>
Review Q1 metrics with Sarah
</li>
<li className='mb-1 text-[#1C1C1C] text-[14px] leading-[1.6]'>
Update API documentation
</li>
<li className='mb-1 text-[#1C1C1C] text-[14px] leading-[1.6]'>
Schedule design review for v2.0
</li>
</ul>
<h2 className='mt-5 mb-3 border-[#E5E5E5] border-b pb-1.5 font-semibold text-[#1C1C1C] text-[16px]'>
Discussion Points
</h2>
<p className='mb-3 text-[#1C1C1C] text-[14px] leading-[1.6]'>
The team agreed to prioritize the new onboarding flow. Key decisions:
</p>
<ol className='mb-3 list-decimal pl-[24px]'>
<li className='mb-1 text-[#1C1C1C] text-[14px] leading-[1.6]'>
Migrate to the new auth provider by end of March
</li>
<li className='mb-1 text-[#1C1C1C] text-[14px] leading-[1.6]'>
Ship the dashboard redesign in two phases
</li>
<li className='mb-1 text-[#1C1C1C] text-[14px] leading-[1.6]'>
Add automated testing for all critical paths
</li>
</ol>
<h2 className='mt-5 mb-3 border-[#E5E5E5] border-b pb-1.5 font-semibold text-[#1C1C1C] text-[16px]'>
Next Steps
</h2>
<p className='mb-3 text-[#1C1C1C] text-[14px] leading-[1.6]'>
Follow up with engineering on the timeline for the API v2 migration. Draft the
proposal for the board meeting next week.
</p>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={MD_COMPONENTS}>
{source}
</ReactMarkdown>
</div>
</motion.div>
</div>
@@ -809,8 +799,79 @@ const LOG_STATUS_STYLES: Record<string, { bg: string; text: string; label: strin
error: { bg: '#FEE2E2', text: '#991B1B', label: 'Error' },
}
interface MockLogDetail {
output: string
spans: { name: string; ms: number; depth: number }[]
}
const MOCK_LOG_DETAILS: MockLogDetail[] = [
{
output: '{\n "result": "processed",\n "emails": 3,\n "status": "complete"\n}',
spans: [
{ name: 'Agent Block', ms: 800, depth: 0 },
{ name: 'search_web', ms: 210, depth: 1 },
{ name: 'Function Block', ms: 180, depth: 0 },
],
},
{
output: '{\n "score": 87,\n "label": "high",\n "confidence": 0.94\n}',
spans: [
{ name: 'Agent Block', ms: 2100, depth: 0 },
{ name: 'hubspot_get_contact', ms: 340, depth: 1 },
{ name: 'Function Block', ms: 180, depth: 0 },
{ name: 'Condition', ms: 50, depth: 0 },
],
},
{
output: '{\n "error": "timeout",\n "message": "LLM request exceeded limit"\n}',
spans: [
{ name: 'Agent Block', ms: 650, depth: 0 },
{ name: 'search_kb', ms: 120, depth: 1 },
],
},
{
output: '{\n "user": "james@globex.io",\n "steps_completed": 4,\n "status": "sent"\n}',
spans: [
{ name: 'Agent Block', ms: 980, depth: 0 },
{ name: 'send_email', ms: 290, depth: 1 },
{ name: 'Function Block', ms: 210, depth: 0 },
{ name: 'Agent Block', ms: 420, depth: 0 },
],
},
{
output: '{\n "records_processed": 142,\n "inserted": 138,\n "errors": 4\n}',
spans: [
{ name: 'Agent Block', ms: 1800, depth: 0 },
{ name: 'salesforce_query', ms: 820, depth: 1 },
{ name: 'Function Block', ms: 340, depth: 0 },
{ name: 'Agent Block', ms: 1200, depth: 0 },
{ name: 'insert_rows', ms: 610, depth: 1 },
],
},
{
output: '{\n "result": "processed",\n "emails": 1,\n "status": "complete"\n}',
spans: [
{ name: 'Agent Block', ms: 720, depth: 0 },
{ name: 'gmail_read', ms: 190, depth: 1 },
{ name: 'Function Block', ms: 160, depth: 0 },
],
},
{
output: '{\n "ticket_id": "TKT-4291",\n "priority": "medium",\n "assigned": "support"\n}',
spans: [
{ name: 'Agent Block', ms: 1400, depth: 0 },
{ name: 'classify_intent', ms: 380, depth: 1 },
{ name: 'Function Block', ms: 220, depth: 0 },
{ name: 'Agent Block', ms: 780, depth: 0 },
],
},
]
const MOCK_LOG_DETAIL_MAX_MS = MOCK_LOG_DETAILS.map((d) => Math.max(...d.spans.map((s) => s.ms)))
function MockFullLogs({ revealedRows }: { revealedRows: number }) {
const [showSidebar, setShowSidebar] = useState(false)
const [selectedRow, setSelectedRow] = useState(0)
useEffect(() => {
if (revealedRows < MOCK_LOG_DATA.length) return
@@ -818,8 +879,6 @@ function MockFullLogs({ revealedRows }: { revealedRows: number }) {
return () => clearTimeout(timer)
}, [revealedRows])
const selectedRow = 0
return (
<div className='relative flex h-full'>
<div className='flex min-w-0 flex-1 flex-col'>
@@ -856,7 +915,11 @@ function MockFullLogs({ revealedRows }: { revealedRows: number }) {
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className={isSelected ? 'bg-[#F5F5F5]' : 'hover:bg-[#FAFAFA]'}
className={cn(
'cursor-pointer',
isSelected ? 'bg-[#F5F5F5]' : 'hover:bg-[#FAFAFA]'
)}
onClick={() => setSelectedRow(i)}
>
<td className='px-[24px] py-[10px] align-middle'>
<span className='flex items-center gap-[12px] font-medium text-[#1C1C1C] text-[14px]'>
@@ -908,24 +971,59 @@ function MockFullLogs({ revealedRows }: { revealedRows: number }) {
transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
style={{ width: '45%' }}
>
<MockLogDetailsSidebar />
<MockLogDetailsSidebar
selectedRow={selectedRow}
onPrev={() => setSelectedRow((r) => Math.max(0, r - 1))}
onNext={() => setSelectedRow((r) => Math.min(MOCK_LOG_DATA.length - 1, r + 1))}
/>
</motion.div>
</div>
)
}
function MockLogDetailsSidebar() {
interface MockLogDetailsSidebarProps {
selectedRow: number
onPrev: () => void
onNext: () => void
}
function MockLogDetailsSidebar({ selectedRow, onPrev, onNext }: MockLogDetailsSidebarProps) {
const row = MOCK_LOG_DATA[selectedRow]
const detail = MOCK_LOG_DETAILS[selectedRow]
const statusStyle = LOG_STATUS_STYLES[row[2]] ?? LOG_STATUS_STYLES.success
const [date, time] = row[1].split(', ')
const color = MOCK_LOG_COLORS[selectedRow]
const maxMs = MOCK_LOG_DETAIL_MAX_MS[selectedRow]
const isPrevDisabled = selectedRow === 0
const isNextDisabled = selectedRow === MOCK_LOG_DATA.length - 1
return (
<div className='flex h-full flex-col px-[14px] pt-[12px]'>
<div className='flex h-full flex-col overflow-y-auto px-[14px] pt-[12px]'>
<div className='flex items-center justify-between'>
<span className='font-medium text-[#1C1C1C] text-[14px]'>Log Details</span>
<div className='flex items-center gap-[1px]'>
<div className='flex h-[24px] w-[24px] items-center justify-center rounded-[4px] text-[#999] hover:bg-[#F5F5F5]'>
<button
type='button'
onClick={onPrev}
disabled={isPrevDisabled}
className={cn(
'flex h-[24px] w-[24px] items-center justify-center rounded-[4px] text-[#999]',
isPrevDisabled ? 'cursor-not-allowed opacity-40' : 'hover:bg-[#F5F5F5]'
)}
>
<ChevronDown className='h-[14px] w-[14px] rotate-180' />
</div>
<div className='flex h-[24px] w-[24px] items-center justify-center rounded-[4px] text-[#999] hover:bg-[#F5F5F5]'>
</button>
<button
type='button'
onClick={onNext}
disabled={isNextDisabled}
className={cn(
'flex h-[24px] w-[24px] items-center justify-center rounded-[4px] text-[#999]',
isNextDisabled ? 'cursor-not-allowed opacity-40' : 'hover:bg-[#F5F5F5]'
)}
>
<ChevronDown className='h-[14px] w-[14px]' />
</div>
</button>
</div>
</div>
@@ -934,8 +1032,8 @@ function MockLogDetailsSidebar() {
<div className='flex w-[120px] shrink-0 flex-col gap-[8px]'>
<span className='font-medium text-[#999] text-[12px]'>Timestamp</span>
<div className='flex items-center gap-[6px]'>
<span className='font-medium text-[#666] text-[13px]'>Mar 17</span>
<span className='font-medium text-[#666] text-[13px]'>2:14 PM</span>
<span className='font-medium text-[#666] text-[13px]'>{date}</span>
<span className='font-medium text-[#666] text-[13px]'>{time}</span>
</div>
</div>
<div className='flex min-w-0 flex-1 flex-col gap-[8px]'>
@@ -944,12 +1042,12 @@ function MockLogDetailsSidebar() {
<div
className='h-[10px] w-[10px] shrink-0 rounded-[3px] border-[1.5px]'
style={{
backgroundColor: '#7C3AED',
borderColor: '#7C3AED60',
backgroundColor: color,
borderColor: `${color}60`,
backgroundClip: 'padding-box',
}}
/>
<span className='truncate font-medium text-[#666] text-[13px]'>Email Bot</span>
<span className='truncate font-medium text-[#666] text-[13px]'>{row[0]}</span>
</div>
</div>
</div>
@@ -957,26 +1055,52 @@ function MockLogDetailsSidebar() {
<div className='flex flex-col'>
<div className='flex h-[42px] items-center justify-between border-[#E5E5E5] border-b px-[8px]'>
<span className='font-medium text-[#999] text-[12px]'>Level</span>
<span className='inline-flex items-center rounded-full bg-[#DCFCE7] px-[8px] py-[2px] font-medium text-[#166534] text-[11px]'>
Success
<span
className='inline-flex items-center rounded-full px-[8px] py-[2px] font-medium text-[11px]'
style={{ backgroundColor: statusStyle.bg, color: statusStyle.text }}
>
{statusStyle.label}
</span>
</div>
<div className='flex h-[42px] items-center justify-between border-[#E5E5E5] border-b px-[8px]'>
<span className='font-medium text-[#999] text-[12px]'>Trigger</span>
<span className='rounded-[4px] bg-[#F5F5F5] px-[6px] py-[2px] text-[#666] text-[11px]'>
API
{row[4]}
</span>
</div>
<div className='flex h-[42px] items-center justify-between px-[8px]'>
<span className='font-medium text-[#999] text-[12px]'>Duration</span>
<span className='font-medium text-[#666] text-[13px]'>1.2s</span>
<span className='font-medium text-[#666] text-[13px]'>{row[5]}</span>
</div>
</div>
<div className='flex flex-col gap-[6px] rounded-[6px] border border-[#E5E5E5] bg-[#FAFAFA] px-[10px] py-[8px]'>
<span className='font-medium text-[#999] text-[12px]'>Workflow Output</span>
<div className='rounded-[6px] bg-[#F0F0F0] p-[10px] font-mono text-[#555] text-[11px] leading-[1.5]'>
{'{\n "result": "processed",\n "emails": 3,\n "status": "complete"\n}'}
{detail.output}
</div>
</div>
<div className='flex flex-col gap-[6px] rounded-[6px] border border-[#E5E5E5] bg-[#FAFAFA] px-[10px] py-[8px]'>
<span className='font-medium text-[#999] text-[12px]'>Trace Spans</span>
<div className='flex flex-col gap-[6px]'>
{detail.spans.map((span, i) => (
<div
key={i}
className={cn('flex flex-col gap-[3px]', span.depth === 1 && 'ml-[12px]')}
>
<div className='flex items-center justify-between'>
<span className='font-mono text-[#555] text-[11px]'>{span.name}</span>
<span className='font-medium text-[#999] text-[11px]'>{span.ms}ms</span>
</div>
<div className='h-[4px] w-full overflow-hidden rounded-full bg-[#F0F0F0]'>
<div
className='h-full rounded-full bg-[#2F6FED]'
style={{ width: `${(span.ms / maxMs) * 100}%` }}
/>
</div>
</div>
))}
</div>
</div>
</div>
@@ -985,6 +1109,8 @@ function MockLogDetailsSidebar() {
}
function MockFullTable({ revealedRows }: { revealedRows: number }) {
const [selectedRow, setSelectedRow] = useState<number | null>(null)
return (
<div className='flex h-full flex-col'>
<div className='flex h-[44px] shrink-0 items-center border-[#E5E5E5] border-b px-[24px]'>
@@ -1037,26 +1163,48 @@ function MockFullTable({ revealedRows }: { revealedRows: number }) {
</tr>
</thead>
<tbody>
{MOCK_TABLE_DATA.slice(0, revealedRows).map((row, i) => (
<motion.tr
key={i}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<td className='border-[#E5E5E5] border-r border-b px-[4px] py-[7px] text-center align-middle'>
<span className='text-[#999] text-[11px] tabular-nums'>{i + 1}</span>
</td>
{row.map((cell, j) => (
{MOCK_TABLE_DATA.slice(0, revealedRows).map((row, i) => {
const isSelected = selectedRow === i
return (
<motion.tr
key={i}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className='cursor-pointer'
onClick={() => setSelectedRow(i)}
>
<td
key={j}
className='border-[#E5E5E5] border-r border-b px-[8px] py-[7px] align-middle'
className={cn(
'border-[#E5E5E5] border-r border-b px-[4px] py-[7px] text-center align-middle',
isSelected ? 'bg-[rgba(37,99,235,0.06)]' : 'hover:bg-[#FAFAFA]'
)}
>
<span className='block truncate text-[#1C1C1C] text-[13px]'>{cell}</span>
<span className='text-[#999] text-[11px] tabular-nums'>{i + 1}</span>
</td>
))}
</motion.tr>
))}
{row.map((cell, j) => (
<td
key={j}
className={cn(
'relative border-[#E5E5E5] border-r border-b px-[8px] py-[7px] align-middle',
isSelected ? 'bg-[rgba(37,99,235,0.06)]' : 'hover:bg-[#FAFAFA]'
)}
>
{isSelected && (
<div
className={cn(
'-bottom-px -top-px pointer-events-none absolute left-0 z-[5] border-[#1a5cf6] border-t border-b',
j === 0 && 'border-l',
j === row.length - 1 ? '-right-px border-r' : 'right-0'
)}
/>
)}
<span className='block truncate text-[#1C1C1C] text-[13px]'>{cell}</span>
</td>
))}
</motion.tr>
)
})}
</tbody>
</table>
</div>

View File

@@ -11,15 +11,19 @@ interface FooterItem {
}
const PRODUCT_LINKS: FooterItem[] = [
{ label: 'Pricing', href: '#pricing' },
{ label: 'Pricing', href: '/#pricing' },
{ label: 'Enterprise', href: 'https://form.typeform.com/to/jqCO12pF', external: true },
{ label: 'Self Hosting', href: 'https://docs.sim.ai/self-hosting', external: true },
{ label: 'MCP', href: 'https://docs.sim.ai/mcp', external: true },
{ label: 'Knowledge Base', href: 'https://docs.sim.ai/knowledgebase', external: true },
{ label: 'Tables', href: 'https://docs.sim.ai/tables', external: true },
{ label: 'API', href: 'https://docs.sim.ai/api-reference/getting-started', external: true },
{ label: 'Status', href: 'https://status.sim.ai', external: true },
]
const RESOURCES_LINKS: FooterItem[] = [
{ label: 'Blog', href: '/blog' },
{ label: 'Templates', href: '/templates' },
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
{ label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true },
{ label: 'Changelog', href: '/changelog' },
@@ -39,6 +43,7 @@ const BLOCK_LINKS: FooterItem[] = [
]
const INTEGRATION_LINKS: FooterItem[] = [
{ label: 'All Integrations →', href: '/integrations' },
{ label: 'Confluence', href: 'https://docs.sim.ai/tools/confluence', external: true },
{ label: 'Slack', href: 'https://docs.sim.ai/tools/slack', external: true },
{ label: 'GitHub', href: 'https://docs.sim.ai/tools/github', external: true },

View File

@@ -1,23 +1,11 @@
import Link from 'next/link'
import { cn } from '@/lib/core/utils/cn'
const FEATURED_POST = {
title: 'Build with Sim for Enterprise',
slug: 'enterprise',
image: '/blog/thumbnails/enterprise.webp',
} as const
const POSTS = [
{ title: 'Introducing Sim v0.5', slug: 'v0-5', image: '/blog/thumbnails/v0-5.webp' },
{ title: '$7M Series A', slug: 'series-a', image: '/blog/thumbnails/series-a.webp' },
{
title: 'Realtime Collaboration',
slug: 'multiplayer',
image: '/blog/thumbnails/multiplayer.webp',
},
{ title: 'Inside the Executor', slug: 'executor', image: '/blog/thumbnails/executor.webp' },
{ title: 'Inside Sim Copilot', slug: 'copilot', image: '/blog/thumbnails/copilot.webp' },
] as const
export interface NavBlogPost {
slug: string
title: string
ogImage: string
}
function BlogCard({
slug,
@@ -63,34 +51,32 @@ function BlogCard({
)
}
export function BlogDropdown() {
interface BlogDropdownProps {
posts: NavBlogPost[]
}
export function BlogDropdown({ posts }: BlogDropdownProps) {
const [featured, ...rest] = posts
if (!featured) return null
return (
<div className='w-[560px] rounded-[5px] border border-[#2A2A2A] bg-[#1C1C1C] p-[16px] shadow-[0_16px_48px_rgba(0,0,0,0.4)]'>
<div className='grid grid-cols-3 gap-[8px]'>
<BlogCard
slug={FEATURED_POST.slug}
image={FEATURED_POST.image}
title={FEATURED_POST.title}
slug={featured.slug}
image={featured.ogImage}
title={featured.title}
imageHeight='190px'
titleSize='13px'
className='col-span-2 row-span-2'
/>
{POSTS.slice(0, 2).map((post) => (
{rest.map((post) => (
<BlogCard
key={post.slug}
slug={post.slug}
image={post.image}
title={post.title}
imageHeight='72px'
/>
))}
{POSTS.slice(2).map((post) => (
<BlogCard
key={post.slug}
slug={post.slug}
image={post.image}
image={post.ogImage}
title={post.title}
imageHeight='72px'
/>

View File

@@ -5,7 +5,10 @@ import Image from 'next/image'
import Link from 'next/link'
import { GithubOutlineIcon } from '@/components/icons'
import { cn } from '@/lib/core/utils/cn'
import { BlogDropdown } from '@/app/(home)/components/navbar/components/blog-dropdown'
import {
BlogDropdown,
type NavBlogPost,
} from '@/app/(home)/components/navbar/components/blog-dropdown'
import { DocsDropdown } from '@/app/(home)/components/navbar/components/docs-dropdown'
import { GitHubStars } from '@/app/(home)/components/navbar/components/github-stars'
import { getBrandConfig } from '@/ee/whitelabeling'
@@ -23,7 +26,7 @@ interface NavLink {
const NAV_LINKS: NavLink[] = [
{ label: 'Docs', href: 'https://docs.sim.ai', external: true, icon: 'chevron', dropdown: 'docs' },
{ label: 'Blog', href: '/blog', icon: 'chevron', dropdown: 'blog' },
{ label: 'Pricing', href: '#pricing' },
{ label: 'Pricing', href: '/#pricing' },
{ label: 'Enterprise', href: 'https://form.typeform.com/to/jqCO12pF', external: true },
]
@@ -32,9 +35,10 @@ const LINK_CELL = 'flex items-center px-[14px]'
interface NavbarProps {
logoOnly?: boolean
blogPosts?: NavBlogPost[]
}
export default function Navbar({ logoOnly = false }: NavbarProps) {
export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps) {
const brand = getBrandConfig()
const [activeDropdown, setActiveDropdown] = useState<DropdownId>(null)
const [hoveredLink, setHoveredLink] = useState<string | null>(null)
@@ -161,7 +165,7 @@ export default function Navbar({ logoOnly = false }: NavbarProps) {
}}
>
{dropdown === 'docs' && <DocsDropdown />}
{dropdown === 'blog' && <BlogDropdown />}
{dropdown === 'blog' && <BlogDropdown posts={blogPosts} />}
</div>
</li>
)

View File

@@ -22,9 +22,10 @@ const PRICING_TIERS: PricingTier[] = [
features: [
'1,000 credits (trial)',
'5GB file storage',
'3 tables · 1,000 rows each',
'5 min execution limit',
'Limited log retention',
'CLI/SDK Access',
'7-day log retention',
'CLI/SDK/MCP Access',
],
cta: { label: 'Get started', href: '/signup' },
},
@@ -36,11 +37,12 @@ const PRICING_TIERS: PricingTier[] = [
billingPeriod: 'per month',
color: '#00F701',
features: [
'6,000 credits/mo',
'+50 daily refresh credits',
'150 runs/min (sync)',
'50 min sync execution limit',
'6,000 credits/mo · +50/day',
'50GB file storage',
'25 tables · 5,000 rows each',
'50 min execution · 150 runs/min',
'Unlimited log retention',
'CLI/SDK/MCP Access',
],
cta: { label: 'Get started', href: '/signup' },
},
@@ -52,11 +54,12 @@ const PRICING_TIERS: PricingTier[] = [
billingPeriod: 'per month',
color: '#FA4EDF',
features: [
'25,000 credits/mo',
'+200 daily refresh credits',
'300 runs/min (sync)',
'50 min sync execution limit',
'25,000 credits/mo · +200/day',
'500GB file storage',
'25 tables · 5,000 rows each',
'50 min execution · 300 runs/min',
'Unlimited log retention',
'CLI/SDK/MCP Access',
],
cta: { label: 'Get started', href: '/signup' },
},
@@ -66,7 +69,15 @@ const PRICING_TIERS: PricingTier[] = [
description: 'For organizations needing security and scale',
price: 'Custom',
color: '#FFCC02',
features: ['Custom infra limits', 'SSO', 'SOC2', 'Self hosting', 'Dedicated support'],
features: [
'Custom credits & infra limits',
'Custom file storage',
'10,000 tables · 1M rows each',
'Custom execution limits',
'Unlimited log retention',
'SSO & SCIM · SOC2 & HIPAA',
'Self hosting · Dedicated support',
],
cta: { label: 'Book a demo', href: '/contact' },
},
]
@@ -114,12 +125,12 @@ function PricingCard({ tier }: PricingCardProps) {
</p>
<div className='mt-4'>
{isEnterprise ? (
<a
<Link
href={tier.cta.href}
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#E5E5E5] px-[10px] font-[430] font-season text-[#1C1C1C] text-[14px] transition-colors hover:bg-[#F0F0F0]'
>
{tier.cta.label}
</a>
</Link>
) : isPro ? (
<Link
href={tier.cta.href}

View File

@@ -1,3 +1,4 @@
import { getAllPostMeta } from '@/lib/blog/registry'
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
import { season } from '@/app/_styles/fonts/season/season'
import {
@@ -28,23 +29,30 @@ import {
* for immediate availability to AI crawlers.
* - Section `id` attributes serve as fragment anchors for precise AI citations.
* - Content ordering prioritizes answer-first patterns: definition (Hero) ->
* examples (Templates) -> capabilities (Features) -> social proof (Collaboration, Testimonials) ->
* pricing (Pricing) -> enterprise (Enterprise).
* examples (Templates) -> capabilities (Features) -> social proof (Collaboration) ->
* enterprise (Enterprise) -> pricing (Pricing) -> testimonials (Testimonials).
*/
export default async function Landing() {
const allPosts = await getAllPostMeta()
const featuredPost = allPosts.find((p) => p.featured) ?? allPosts[0]
const recentPosts = allPosts.filter((p) => p !== featuredPost).slice(0, 4)
const blogPosts = [featuredPost, ...recentPosts]
.filter(Boolean)
.map((p) => ({ slug: p.slug, title: p.title, ogImage: p.ogImage }))
return (
<div className={`${season.variable} ${martianMono.variable} min-h-screen bg-[#1C1C1C]`}>
<StructuredData />
<header>
<Navbar />
<Navbar blogPosts={blogPosts} />
</header>
<main>
<Hero />
<Templates />
<Features />
<Collaboration />
<Pricing />
<Enterprise />
<Pricing />
<Testimonials />
</main>
<Footer />

View File

@@ -0,0 +1,54 @@
'use client'
import { useState } from 'react'
import { ChevronDown } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { FAQItem } from '@/app/(landing)/integrations/data/types'
interface IntegrationFAQProps {
faqs: FAQItem[]
}
export function IntegrationFAQ({ faqs }: IntegrationFAQProps) {
const [openIndex, setOpenIndex] = useState<number | null>(0)
return (
<div className='divide-y divide-[#2A2A2A]'>
{faqs.map(({ question, answer }, index) => {
const isOpen = openIndex === index
return (
<div key={question}>
<button
type='button'
onClick={() => setOpenIndex(isOpen ? null : index)}
className='flex w-full items-start justify-between gap-4 py-5 text-left'
aria-expanded={isOpen}
>
<span
className={cn(
'font-[500] text-[15px] leading-snug transition-colors',
isOpen ? 'text-[#ECECEC]' : 'text-[#999] hover:text-[#ECECEC]'
)}
>
{question}
</span>
<ChevronDown
className={cn(
'mt-0.5 h-4 w-4 shrink-0 text-[#555] transition-transform duration-200',
isOpen ? 'rotate-180' : 'rotate-0'
)}
aria-hidden='true'
/>
</button>
{isOpen && (
<div className='pb-5'>
<p className='text-[#999] text-[14px] leading-[1.75]'>{answer}</p>
</div>
)}
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,28 @@
'use client'
import { useRouter } from 'next/navigation'
import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
interface TemplateCardButtonProps {
prompt: string
children: React.ReactNode
}
export function TemplateCardButton({ prompt, children }: TemplateCardButtonProps) {
const router = useRouter()
function handleClick() {
LandingPromptStorage.store(prompt)
router.push('/signup')
}
return (
<button
type='button'
onClick={handleClick}
className='group flex w-full flex-col items-start rounded-lg border border-[#2A2A2A] bg-[#242424] p-5 text-left transition-colors hover:border-[#3d3d3d] hover:bg-[#2A2A2A]'
>
{children}
</button>
)
}

View File

@@ -0,0 +1,761 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { TEMPLATES } from '@/app/workspace/[workspaceId]/home/components/template-prompts/consts'
import { IntegrationIcon } from '../components/integration-icon'
import { blockTypeToIconMap } from '../data/icon-mapping'
import integrations from '../data/integrations.json'
import { POPULAR_WORKFLOWS } from '../data/popular-workflows'
import type { AuthType, FAQItem, Integration } from '../data/types'
import { IntegrationFAQ } from './components/integration-faq'
import { TemplateCardButton } from './components/template-card-button'
const allIntegrations = integrations as Integration[]
const INTEGRATION_COUNT = allIntegrations.length
/** Fast O(1) lookups — avoids repeated linear scans inside render loops. */
const byName = new Map(allIntegrations.map((i) => [i.name, i]))
const bySlug = new Map(allIntegrations.map((i) => [i.slug, i]))
const byType = new Map(allIntegrations.map((i) => [i.type, i]))
/** Returns workflow pairs that feature the given integration on either side. */
function getPairsFor(name: string) {
return POPULAR_WORKFLOWS.filter((p) => p.from === name || p.to === name)
}
/**
* Returns up to `limit` related integration slugs.
*
* Scoring:
* +100 — integration appears as a workflow pair partner (explicit editorial signal)
* +N — N operation names shared with the current integration (semantic similarity)
*
* This means genuine partners always rank first; operation-similar integrations
* (e.g. Slack → Teams → Discord for "Send Message") fill the rest organically.
*/
function getRelatedSlugs(
name: string,
slug: string,
operations: Integration['operations'],
limit = 6
): string[] {
const partners = new Set(getPairsFor(name).map((p) => (p.from === name ? p.to : p.from)))
const currentOps = new Set(operations.map((o) => o.name.toLowerCase()))
return allIntegrations
.filter((i) => i.slug !== slug)
.map((i) => ({
slug: i.slug,
score:
(partners.has(i.name) ? 100 : 0) +
i.operations.filter((o) => currentOps.has(o.name.toLowerCase())).length,
}))
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map(({ slug: s }) => s)
}
const AUTH_STEP: Record<AuthType, string> = {
oauth: 'Authenticate with one-click OAuth — no credentials to copy-paste.',
'api-key': 'Add your API key to authenticate — find it in your account settings.',
none: 'Authenticate your account to connect.',
}
/**
* Generates targeted FAQs from integration metadata.
* Questions mirror real search queries to drive FAQPage rich snippets.
*/
function buildFAQs(integration: Integration): FAQItem[] {
const { name, description, operations, triggers, authType } = integration
const topOps = operations.slice(0, 5)
const topOpNames = topOps.map((o) => o.name)
const pairs = getPairsFor(name)
const authStep = AUTH_STEP[authType]
const faqs: FAQItem[] = [
{
question: `What is Sim's ${name} integration?`,
answer: `Sim's ${name} integration lets you build AI-powered workflows that automate tasks in ${name} without writing code. ${description} You can connect ${name} to hundreds of other services in the same workflow — from CRMs and spreadsheets to messaging tools and databases.`,
},
{
question: `What can I automate with ${name} in Sim?`,
answer:
topOpNames.length > 0
? `With Sim you can: ${topOpNames.join('; ')}${operations.length > 5 ? `; and ${operations.length - 5} more tools` : ''}. Each action runs inside an AI agent block, so you can combine ${name} with LLM reasoning, conditional logic, and data from any other connected service.`
: `Sim lets you automate ${name} workflows by connecting it to an AI agent that can read from it, write to it, and chain it together with other services — all driven by natural-language instructions instead of rigid rules.`,
},
{
question: `How do I connect ${name} to Sim?`,
answer: `Getting started takes under five minutes: (1) Create a free account at sim.ai. (2) Open a new workflow. (3) Drag a ${name} block onto the canvas. (4) ${authStep} (5) Choose the tool you want to use, wire it to the inputs you need, and click Run. Your automation is live.`,
},
...(topOpNames.length >= 2
? [
{
question: `How do I ${topOpNames[0].toLowerCase()} with ${name} in Sim?`,
answer: `Add a ${name} block to your workflow and select "${topOpNames[0]}" as the tool. Fill in the required fields — you can reference outputs from earlier steps, such as text generated by an AI agent or data fetched from another integration. No code is required.`,
},
]
: []),
...(pairs.length > 0
? [
{
question: `Can I connect ${name} to ${pairs[0].from === name ? pairs[0].to : pairs[0].from} with Sim?`,
answer: `Yes. ${pairs[0].description} In Sim, you set this up by adding both a ${name} block and a ${pairs[0].from === name ? pairs[0].to : pairs[0].from} block to the same workflow and connecting them through an AI agent that orchestrates the logic between them.`,
},
]
: []),
...(triggers.length > 0
? [
{
question: `Can ${name} trigger a Sim workflow automatically?`,
answer: `Yes. ${name} supports ${triggers.length} webhook trigger${triggers.length === 1 ? '' : 's'} that can instantly start a Sim workflow: ${triggers.map((t) => t.name).join(', ')}. No polling needed — the workflow fires the moment the event occurs in ${name}.`,
},
]
: []),
{
question: `What ${name} tools does Sim support?`,
answer:
operations.length > 0
? `Sim supports ${operations.length} ${name} tool${operations.length === 1 ? '' : 's'}: ${operations.map((o) => o.name).join(', ')}.`
: `Sim supports core ${name} tools for reading and writing data, triggering actions, and integrating with your other services. See the full list in the Sim documentation.`,
},
{
question: `Is the ${name} integration free to use?`,
answer: `Yes — Sim's free plan includes access to the ${name} integration and every other integration in the library. No credit card is needed to get started. Visit sim.ai to create your account.`,
},
]
return faqs
}
export async function generateStaticParams() {
return allIntegrations.map((i) => ({ slug: i.slug }))
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>
}): Promise<Metadata> {
const { slug } = await params
const integration = bySlug.get(slug)
if (!integration) return {}
const { name, description, operations } = integration
const opSample = operations
.slice(0, 3)
.map((o) => o.name)
.join(', ')
const metaDesc = `Automate ${name} with AI-powered workflows on Sim. ${description.slice(0, 100).trimEnd()}. Free to start.`
return {
title: `${name} Integration`,
description: metaDesc,
keywords: [
`${name} automation`,
`${name} integration`,
`automate ${name}`,
`connect ${name}`,
`${name} workflow`,
`${name} AI automation`,
...(opSample ? [`${name} ${opSample}`] : []),
'workflow automation',
'no-code automation',
'AI agent workflow',
],
openGraph: {
title: `${name} Integration — AI Workflow Automation | Sim`,
description: `Connect ${name} to ${INTEGRATION_COUNT - 1}+ tools using AI agents. ${description.slice(0, 100).trimEnd()}.`,
url: `https://sim.ai/integrations/${slug}`,
type: 'website',
images: [{ url: 'https://sim.ai/opengraph-image.png', width: 1200, height: 630 }],
},
twitter: {
card: 'summary_large_image',
title: `${name} Integration | Sim`,
description: `Automate ${name} with AI-powered workflows. Connect to ${INTEGRATION_COUNT - 1}+ tools. Free to start.`,
},
alternates: { canonical: `https://sim.ai/integrations/${slug}` },
}
}
export default async function IntegrationPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const integration = bySlug.get(slug)
if (!integration) notFound()
const { name, description, longDescription, bgColor, docsUrl, operations, triggers, authType } =
integration
const IconComponent = blockTypeToIconMap[integration.type]
const faqs = buildFAQs(integration)
const relatedSlugs = getRelatedSlugs(name, slug, operations)
const relatedIntegrations = relatedSlugs
.map((s) => bySlug.get(s))
.filter((i): i is Integration => i !== undefined)
const featuredPairs = getPairsFor(name)
const baseType = integration.type.replace(/_v\d+$/, '')
const matchingTemplates = TEMPLATES.filter(
(t) =>
t.integrationBlockTypes.includes(integration.type) ||
t.integrationBlockTypes.includes(baseType)
)
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://sim.ai' },
{
'@type': 'ListItem',
position: 2,
name: 'Integrations',
item: 'https://sim.ai/integrations',
},
{ '@type': 'ListItem', position: 3, name, item: `https://sim.ai/integrations/${slug}` },
],
}
const softwareAppJsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: `${name} Integration`,
description,
url: `https://sim.ai/integrations/${slug}`,
applicationCategory: 'BusinessApplication',
operatingSystem: 'Web',
featureList: operations.map((o) => o.name),
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
}
const howToJsonLd = {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: `How to automate ${name} with Sim`,
description: `Step-by-step guide to connecting ${name} to AI-powered workflows in Sim.`,
step: [
{
'@type': 'HowToStep',
position: 1,
name: 'Create a free Sim account',
text: 'Sign up at sim.ai — no credit card required.',
},
{
'@type': 'HowToStep',
position: 2,
name: `Add a ${name} block`,
text: `Open a workflow, drag a ${name} block onto the canvas, and authenticate with your ${name} credentials.`,
},
{
'@type': 'HowToStep',
position: 3,
name: 'Configure and run',
text: `Choose the operation you want, connect it to an AI agent, and run your workflow. Automate anything in ${name} without code.`,
},
],
}
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqs.map(({ question, answer }) => ({
'@type': 'Question',
name: question,
acceptedAnswer: { '@type': 'Answer', text: answer },
})),
}
return (
<>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
/>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(softwareAppJsonLd) }}
/>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(howToJsonLd) }}
/>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
/>
<div className='mx-auto max-w-[1200px] px-6 py-12 sm:px-8 md:px-12'>
{/* Breadcrumb */}
<nav
aria-label='Breadcrumb'
className='mb-10 flex items-center gap-2 text-[#555] text-[13px]'
>
<Link href='/' className='transition-colors hover:text-[#999]'>
Home
</Link>
<span aria-hidden='true'>/</span>
<Link href='/integrations' className='transition-colors hover:text-[#999]'>
Integrations
</Link>
<span aria-hidden='true'>/</span>
<span className='text-[#999]'>{name}</span>
</nav>
{/* Hero */}
<section aria-labelledby='integration-heading' className='mb-16'>
<div className='mb-6 flex items-center gap-5'>
<IntegrationIcon
bgColor={bgColor}
name={name}
Icon={IconComponent}
className='h-16 w-16 rounded-xl'
iconClassName='h-8 w-8'
fallbackClassName='text-[26px]'
aria-hidden='true'
/>
<div>
<p className='mb-0.5 text-[#555] text-[12px]'>Integration</p>
<h1
id='integration-heading'
className='font-[500] text-[#ECECEC] text-[36px] leading-tight sm:text-[44px]'
>
{name}
</h1>
</div>
</div>
<p className='mb-8 max-w-[700px] text-[#999] text-[17px] leading-[1.7]'>{description}</p>
{/* CTAs */}
<div className='flex flex-wrap gap-[8px]'>
<a
href='https://sim.ai'
className='inline-flex h-[32px] items-center rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[10px] font-[430] font-season text-[#1C1C1C] text-[14px] transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
>
Start building free
</a>
<a
href={docsUrl}
target='_blank'
rel='noopener noreferrer'
className='inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-[#3d3d3d] px-[10px] font-[430] font-season text-[#ECECEC] text-[14px] transition-colors hover:bg-[#2A2A2A]'
>
View docs
<svg
aria-hidden='true'
className='h-3 w-3'
fill='none'
stroke='currentColor'
strokeWidth={2}
viewBox='0 0 24 24'
>
<path d='M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6' />
<polyline points='15 3 21 3 21 9' />
<line x1='10' x2='21' y1='14' y2='3' />
</svg>
</a>
</div>
</section>
{/* Two-column layout */}
<div className='grid grid-cols-1 gap-16 lg:grid-cols-[1fr_300px]'>
{/* Main column */}
<div className='min-w-0 space-y-16'>
{/* Overview */}
{longDescription && (
<section aria-labelledby='overview-heading'>
<h2 id='overview-heading' className='mb-4 font-[500] text-[#ECECEC] text-[20px]'>
Overview
</h2>
<p className='text-[#999] text-[15px] leading-[1.8]'>{longDescription}</p>
</section>
)}
{/* How to automate — targets "how to connect X" queries */}
<section aria-labelledby='how-it-works-heading'>
<h2 id='how-it-works-heading' className='mb-6 font-[500] text-[#ECECEC] text-[20px]'>
How to automate {name} with Sim
</h2>
<ol className='space-y-4' aria-label='Steps to set up automation'>
{[
{
step: '01',
title: 'Create a free account',
body: 'Sign up at sim.ai in seconds. No credit card required. Your workspace is ready immediately.',
},
{
step: '02',
title: `Add a ${name} block`,
body:
authType === 'oauth'
? `Open a workflow, drag a ${name} block onto the canvas, and connect your account with one-click OAuth.`
: authType === 'api-key'
? `Open a workflow, drag a ${name} block onto the canvas, and paste in your ${name} API key.`
: `Open a workflow, drag a ${name} block onto the canvas, and authenticate your account.`,
},
{
step: '03',
title: 'Configure, connect, and run',
body: `Pick the tool you need, wire in an AI agent for reasoning or data transformation, and run. Your ${name} automation is live.`,
},
].map(({ step, title, body }) => (
<li key={step} className='flex gap-4'>
<span
className='mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-[#3d3d3d] font-[500] text-[#555] text-[11px]'
aria-hidden='true'
>
{step}
</span>
<div>
<h3 className='mb-1 font-[500] text-[#ECECEC] text-[15px]'>{title}</h3>
<p className='text-[#999] text-[14px] leading-relaxed'>{body}</p>
</div>
</li>
))}
</ol>
</section>
{/* Triggers */}
{triggers.length > 0 && (
<section aria-labelledby='triggers-heading'>
<h2 id='triggers-heading' className='mb-2 font-[500] text-[#ECECEC] text-[20px]'>
Triggers
</h2>
<p className='mb-6 text-[#999] text-[14px]'>
These events in {name} can automatically start a Sim workflow no polling
required.
</p>
<ul
className='grid grid-cols-1 gap-3 sm:grid-cols-2'
aria-label={`${name} triggers`}
>
{triggers.map((trigger) => (
<li
key={trigger.id}
className='rounded-lg border border-[#2A2A2A] bg-[#242424] p-4'
>
<div className='mb-2 flex items-center gap-2'>
<span className='inline-flex items-center gap-1 rounded-[4px] bg-[#2A2A2A] px-1.5 py-0.5 font-[500] text-[#ECECEC] text-[11px]'>
<svg
aria-hidden='true'
className='h-2.5 w-2.5'
fill='none'
stroke='currentColor'
strokeWidth={2.5}
viewBox='0 0 24 24'
>
<polygon points='13 2 3 14 12 14 11 22 21 10 12 10 13 2' />
</svg>
Trigger
</span>
</div>
<p className='font-[500] text-[#ECECEC] text-[13px]'>{trigger.name}</p>
{trigger.description && (
<p className='mt-1 text-[#999] text-[12px] leading-relaxed'>
{trigger.description}
</p>
)}
</li>
))}
</ul>
</section>
)}
{/* Popular workflows featuring this integration */}
{featuredPairs.length > 0 && (
<section aria-labelledby='workflows-heading'>
<h2 id='workflows-heading' className='mb-2 font-[500] text-[#ECECEC] text-[20px]'>
Popular workflows with {name}
</h2>
<p className='mb-6 text-[#999] text-[14px]'>
Common automation patterns teams build on Sim using {name}.
</p>
<ul
className='grid grid-cols-1 gap-4 sm:grid-cols-2'
aria-label='Popular workflow combinations'
>
{featuredPairs.map(({ from, to, headline, description: desc }) => {
const fromInt = byName.get(from)
const toInt = byName.get(to)
const FromIcon = fromInt ? blockTypeToIconMap[fromInt.type] : undefined
const ToIcon = toInt ? blockTypeToIconMap[toInt.type] : undefined
const fromBg = fromInt?.bgColor ?? '#6B7280'
const toBg = toInt?.bgColor ?? '#6B7280'
return (
<li key={`${from}-${to}`}>
<div className='h-full rounded-lg border border-[#2A2A2A] bg-[#242424] p-5'>
<div className='mb-3 flex items-center gap-2 text-[12px]'>
<span className='inline-flex items-center gap-1 rounded-[3px] bg-[#2A2A2A] px-1.5 py-0.5 font-[500] text-[#ECECEC]'>
{FromIcon && (
<IntegrationIcon
bgColor={fromBg}
name={from}
Icon={FromIcon}
as='span'
className='h-3.5 w-3.5 rounded-[2px]'
iconClassName='h-2.5 w-2.5'
aria-hidden='true'
/>
)}
{from}
</span>
<span className='text-[#555]' aria-hidden='true'>
</span>
<span className='inline-flex items-center gap-1 rounded-[3px] bg-[#2A2A2A] px-1.5 py-0.5 font-[500] text-[#ECECEC]'>
{ToIcon && (
<IntegrationIcon
bgColor={toBg}
name={to}
Icon={ToIcon}
as='span'
className='h-3.5 w-3.5 rounded-[2px]'
iconClassName='h-2.5 w-2.5'
aria-hidden='true'
/>
)}
{to}
</span>
</div>
<p className='mb-1 font-[500] text-[#ECECEC] text-[14px]'>{headline}</p>
<p className='text-[#999] text-[13px] leading-relaxed'>{desc}</p>
</div>
</li>
)
})}
</ul>
</section>
)}
{/* Workflow templates */}
{matchingTemplates.length > 0 && (
<section aria-labelledby='templates-heading'>
<h2 id='templates-heading' className='mb-2 font-[500] text-[#ECECEC] text-[20px]'>
Workflow templates
</h2>
<p className='mb-6 text-[#999] text-[14px]'>
Ready-to-use workflows featuring {name}. Click any to build it instantly.
</p>
<ul
className='grid grid-cols-1 gap-3 sm:grid-cols-2'
aria-label='Workflow templates'
>
{matchingTemplates.map((template) => {
const allTypes = [
integration.type,
...template.integrationBlockTypes.filter((bt) => bt !== integration.type),
]
return (
<li key={template.title}>
<TemplateCardButton prompt={template.prompt}>
{/* Integration icons */}
<div className='mb-3 flex items-center gap-1.5'>
{allTypes.map((bt) => {
const int = byType.get(bt)
const intName = int?.name ?? bt
return (
<IntegrationIcon
key={bt}
bgColor={int?.bgColor ?? '#6B7280'}
name={intName}
Icon={blockTypeToIconMap[bt]}
className='h-6 w-6 rounded-[5px]'
iconClassName='h-3.5 w-3.5'
fallbackClassName='text-[9px]'
title={intName}
aria-label={intName}
/>
)
})}
</div>
<p className='mb-3 font-[500] text-[#ECECEC] text-[13px] leading-snug'>
{template.title}
</p>
<span className='text-[#555] text-[12px] transition-colors group-hover:text-[#999]'>
Try this workflow
</span>
</TemplateCardButton>
</li>
)
})}
</ul>
</section>
)}
{/* Tools */}
{operations.length > 0 && (
<section aria-labelledby='tools-heading'>
<h2 id='tools-heading' className='mb-2 font-[500] text-[#ECECEC] text-[20px]'>
Supported tools
</h2>
<p className='mb-6 text-[#999] text-[14px]'>
{operations.length} {name} tool{operations.length === 1 ? '' : 's'} available in
Sim
</p>
<ul
className='grid grid-cols-1 gap-2 sm:grid-cols-2'
aria-label={`${name} supported tools`}
>
{operations.map((op) => (
<li
key={op.name}
className='rounded-[6px] border border-[#2A2A2A] bg-[#242424] px-3.5 py-3'
>
<p className='font-[500] text-[#ECECEC] text-[13px]'>{op.name}</p>
{op.description && (
<p className='mt-0.5 text-[#555] text-[12px] leading-relaxed'>
{op.description}
</p>
)}
</li>
))}
</ul>
</section>
)}
{/* FAQ */}
<section aria-labelledby='faq-heading'>
<h2 id='faq-heading' className='mb-8 font-[500] text-[#ECECEC] text-[20px]'>
Frequently asked questions
</h2>
<IntegrationFAQ faqs={faqs} />
</section>
</div>
{/* Sidebar */}
<aside className='space-y-5' aria-label='Integration details'>
{/* Quick details */}
<div className='rounded-lg border border-[#2A2A2A] bg-[#242424] p-5'>
<h3 className='mb-4 font-[500] text-[#ECECEC] text-[14px]'>Details</h3>
<dl className='space-y-3 text-[13px]'>
{operations.length > 0 && (
<div>
<dt className='text-[#555]'>Tools</dt>
<dd className='text-[#ECECEC]'>{operations.length} supported</dd>
</div>
)}
{triggers.length > 0 && (
<div>
<dt className='text-[#555]'>Triggers</dt>
<dd className='text-[#ECECEC]'>{triggers.length} available</dd>
</div>
)}
<div>
<dt className='text-[#555]'>Auth</dt>
<dd className='text-[#ECECEC]'>
{authType === 'oauth'
? 'One-click OAuth'
: authType === 'api-key'
? 'API key'
: 'None required'}
</dd>
</div>
<div>
<dt className='text-[#555]'>Pricing</dt>
<dd className='text-[#ECECEC]'>Free to start</dd>
</div>
</dl>
<a
href='https://sim.ai'
className='mt-5 flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] font-[430] font-season text-[#1C1C1C] text-[13px] transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
>
Get started free
</a>
</div>
{/* Docs */}
<div className='rounded-lg border border-[#2A2A2A] bg-[#242424] p-5'>
<h3 className='mb-2 font-[500] text-[#ECECEC] text-[14px]'>Documentation</h3>
<p className='mb-4 text-[#999] text-[13px] leading-relaxed'>
Full API reference, authentication setup, and usage examples for {name}.
</p>
<a
href={docsUrl}
target='_blank'
rel='noopener noreferrer'
className='inline-flex items-center gap-1.5 text-[#999] text-[13px] transition-colors hover:text-[#ECECEC]'
>
docs.sim.ai
<svg
aria-hidden='true'
className='h-3 w-3'
fill='none'
stroke='currentColor'
strokeWidth={2}
viewBox='0 0 24 24'
>
<path d='M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6' />
<polyline points='15 3 21 3 21 9' />
<line x1='10' x2='21' y1='14' y2='3' />
</svg>
</a>
</div>
{/* Related integrations — internal linking for SEO */}
{relatedIntegrations.length > 0 && (
<div className='rounded-lg border border-[#2A2A2A] bg-[#242424] p-5'>
<h3 className='mb-4 font-[500] text-[#ECECEC] text-[14px]'>Related integrations</h3>
<ul className='space-y-2'>
{relatedIntegrations.map((rel) => (
<li key={rel.slug}>
<Link
href={`/integrations/${rel.slug}`}
className='flex items-center gap-2.5 rounded-[6px] p-1.5 text-[#999] text-[13px] transition-colors hover:bg-[#2A2A2A] hover:text-[#ECECEC]'
>
<IntegrationIcon
bgColor={rel.bgColor}
name={rel.name}
Icon={blockTypeToIconMap[rel.type]}
as='span'
className='h-6 w-6 rounded-[4px]'
iconClassName='h-3.5 w-3.5'
fallbackClassName='text-[10px]'
aria-hidden='true'
/>
{rel.name}
</Link>
</li>
))}
</ul>
<Link
href='/integrations'
className='mt-4 block text-[#555] text-[12px] transition-colors hover:text-[#999]'
>
All integrations
</Link>
</div>
)}
</aside>
</div>
{/* Bottom CTA */}
<section
aria-labelledby='cta-heading'
className='mt-20 rounded-xl border border-[#2A2A2A] bg-[#242424] p-8 text-center sm:p-12'
>
<h2
id='cta-heading'
className='mb-3 font-[500] text-[#ECECEC] text-[28px] sm:text-[34px]'
>
Start automating {name} today
</h2>
<p className='mx-auto mb-8 max-w-[480px] text-[#999] text-[16px] leading-relaxed'>
Build your first AI workflow with {name} in minutes. Connect to every tool your team
uses. Free to start no credit card required.
</p>
<a
href='https://sim.ai'
className='inline-flex h-[32px] items-center rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[10px] font-[430] font-season text-[#1C1C1C] text-[14px] transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
>
Build for free
</a>
</section>
</div>
</>
)
}

View File

@@ -0,0 +1,55 @@
import type { ComponentType, SVGProps } from 'react'
import Link from 'next/link'
import { Badge } from '@/components/emcn'
import type { Integration } from '@/app/(landing)/integrations/data/types'
import { IntegrationIcon } from './integration-icon'
interface IntegrationCardProps {
integration: Integration
IconComponent?: ComponentType<SVGProps<SVGSVGElement>>
}
export function IntegrationCard({ integration, IconComponent }: IntegrationCardProps) {
const { slug, name, description, bgColor, operationCount, triggerCount } = integration
return (
<Link
href={`/integrations/${slug}`}
className='group flex flex-col rounded-lg border border-[#2A2A2A] bg-[#242424] p-4 transition-colors hover:border-[#3d3d3d] hover:bg-[#2A2A2A]'
aria-label={`${name} integration`}
>
<IntegrationIcon
bgColor={bgColor}
name={name}
Icon={IconComponent}
className='mb-3 h-10 w-10 rounded-lg'
aria-hidden='true'
/>
{/* Name */}
<h3 className='mb-1 font-[500] text-[#ECECEC] text-[14px] leading-snug'>{name}</h3>
{/* Description — clamped to 2 lines */}
<p className='mb-3 line-clamp-2 flex-1 text-[#999] text-[12px] leading-relaxed'>
{description}
</p>
{/* Footer row */}
<div className='flex flex-wrap items-center gap-1.5'>
{operationCount > 0 && (
<Badge className='border-0 bg-[#333] text-[#999] text-[11px]'>
{operationCount} {operationCount === 1 ? 'tool' : 'tools'}
</Badge>
)}
{triggerCount > 0 && (
<Badge className='border-0 bg-[#333] text-[#999] text-[11px]'>
{triggerCount} {triggerCount === 1 ? 'trigger' : 'triggers'}
</Badge>
)}
<span className='ml-auto text-[#555] text-[12px] transition-colors group-hover:text-[#999]'>
Learn more
</span>
</div>
</Link>
)
}

View File

@@ -0,0 +1,71 @@
'use client'
import { useMemo, useState } from 'react'
import { Input } from '@/components/emcn'
import { blockTypeToIconMap } from '@/app/(landing)/integrations/data/icon-mapping'
import type { Integration } from '@/app/(landing)/integrations/data/types'
import { IntegrationCard } from './integration-card'
interface IntegrationGridProps {
integrations: Integration[]
}
export function IntegrationGrid({ integrations }: IntegrationGridProps) {
const [query, setQuery] = useState('')
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return integrations
return integrations.filter(
(i) =>
i.name.toLowerCase().includes(q) ||
i.description.toLowerCase().includes(q) ||
i.operations.some(
(op) => op.name.toLowerCase().includes(q) || op.description.toLowerCase().includes(q)
) ||
i.triggers.some((t) => t.name.toLowerCase().includes(q))
)
}, [integrations, query])
return (
<div>
<div className='relative mb-8 max-w-[480px]'>
<svg
aria-hidden='true'
className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-4 w-4 text-[#555]'
fill='none'
stroke='currentColor'
strokeWidth={2}
viewBox='0 0 24 24'
>
<circle cx={11} cy={11} r={8} />
<path d='m21 21-4.35-4.35' />
</svg>
<Input
type='search'
placeholder='Search integrations, tools, or triggers…'
value={query}
onChange={(e) => setQuery(e.target.value)}
className='pl-9'
aria-label='Search integrations'
/>
</div>
{filtered.length === 0 ? (
<p className='py-12 text-center text-[#555] text-[15px]'>
No integrations found for &ldquo;{query}&rdquo;
</p>
) : (
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4'>
{filtered.map((integration) => (
<IntegrationCard
key={integration.type}
integration={integration}
IconComponent={blockTypeToIconMap[integration.type]}
/>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,54 @@
import type { ComponentType, ElementType, HTMLAttributes, SVGProps } from 'react'
import { cn } from '@/lib/core/utils/cn'
import { isLightBg } from '@/app/(landing)/integrations/data/utils'
interface IntegrationIconProps extends HTMLAttributes<HTMLElement> {
bgColor: string
/** Integration name — used for the fallback initial letter. */
name: string
/** Optional icon component. When absent, renders the first letter of `name`. */
Icon?: ComponentType<SVGProps<SVGSVGElement>> | null
/** Tailwind size + rounding classes for the container. Default: `h-10 w-10 rounded-lg` */
className?: string
/** Tailwind size classes for the icon SVG. Default: `h-5 w-5` */
iconClassName?: string
/** Tailwind text-size class for the fallback letter. Default: `text-[15px]` */
fallbackClassName?: string
/** Rendered HTML element. Default: `div` */
as?: ElementType
}
/**
* Colored icon box used across integration listing and detail pages.
* Renders an integration icon over a brand-colored background, falling back
* to the integration's initial letter when no icon is available.
*/
export function IntegrationIcon({
bgColor,
name,
Icon,
className,
iconClassName = 'h-5 w-5',
fallbackClassName = 'text-[15px]',
as: Tag = 'div',
...rest
}: IntegrationIconProps) {
const isLight = isLightBg(bgColor)
const fgColor = isLight ? 'text-[#1C1C1C]' : 'text-white'
return (
<Tag
className={cn('flex shrink-0 items-center justify-center', className)}
style={{ background: bgColor }}
{...rest}
>
{Icon ? (
<Icon className={cn(iconClassName, fgColor)} />
) : (
<span className={cn('font-[500] leading-none', fallbackClassName, fgColor)}>
{name.charAt(0)}
</span>
)}
</Tag>
)
}

View File

@@ -0,0 +1,347 @@
// Auto-generated file - do not edit manually
// Generated by scripts/generate-docs.ts
// Maps block types to their icon component references for the integrations page
import type { ComponentType, SVGProps } from 'react'
import {
A2AIcon,
AhrefsIcon,
AirtableIcon,
AirweaveIcon,
AlgoliaIcon,
AmplitudeIcon,
ApifyIcon,
ApolloIcon,
ArxivIcon,
AsanaIcon,
AshbyIcon,
AttioIcon,
BoxCompanyIcon,
BrainIcon,
BrandfetchIcon,
BrowserUseIcon,
CalComIcon,
CalendlyIcon,
CirclebackIcon,
ClayIcon,
ClerkIcon,
CloudflareIcon,
ConfluenceIcon,
CursorIcon,
DatabricksIcon,
DatadogIcon,
DevinIcon,
DiscordIcon,
DocumentIcon,
DocuSignIcon,
DropboxIcon,
DsPyIcon,
DubIcon,
DuckDuckGoIcon,
DynamoDBIcon,
ElasticsearchIcon,
ElevenLabsIcon,
EnrichSoIcon,
EvernoteIcon,
ExaAIIcon,
EyeIcon,
FathomIcon,
FirecrawlIcon,
FirefliesIcon,
GammaIcon,
GithubIcon,
GitLabIcon,
GmailIcon,
GongIcon,
GoogleAdsIcon,
GoogleBigQueryIcon,
GoogleBooksIcon,
GoogleCalendarIcon,
GoogleContactsIcon,
GoogleDocsIcon,
GoogleDriveIcon,
GoogleFormsIcon,
GoogleGroupsIcon,
GoogleIcon,
GoogleMapsIcon,
GoogleMeetIcon,
GooglePagespeedIcon,
GoogleSheetsIcon,
GoogleSlidesIcon,
GoogleTasksIcon,
GoogleTranslateIcon,
GoogleVaultIcon,
GrafanaIcon,
GrainIcon,
GreenhouseIcon,
GreptileIcon,
HexIcon,
HubspotIcon,
HuggingFaceIcon,
HunterIOIcon,
ImageIcon,
IncidentioIcon,
IntercomIcon,
JinaAIIcon,
JiraIcon,
JiraServiceManagementIcon,
KalshiIcon,
LangsmithIcon,
LemlistIcon,
LinearIcon,
LinkedInIcon,
LinkupIcon,
LoopsIcon,
LumaIcon,
MailchimpIcon,
MailgunIcon,
MailServerIcon,
Mem0Icon,
MicrosoftDataverseIcon,
MicrosoftExcelIcon,
MicrosoftOneDriveIcon,
MicrosoftPlannerIcon,
MicrosoftSharepointIcon,
MicrosoftTeamsIcon,
MistralIcon,
MongoDBIcon,
MySQLIcon,
Neo4jIcon,
NotionIcon,
ObsidianIcon,
OktaIcon,
OnePasswordIcon,
OpenAIIcon,
OutlookIcon,
PackageSearchIcon,
PagerDutyIcon,
ParallelIcon,
PerplexityIcon,
PineconeIcon,
PipedriveIcon,
PolymarketIcon,
PostgresIcon,
PosthogIcon,
PulseIcon,
QdrantIcon,
RDSIcon,
RedditIcon,
RedisIcon,
ReductoIcon,
ResendIcon,
RevenueCatIcon,
S3Icon,
SalesforceIcon,
SearchIcon,
SendgridIcon,
SentryIcon,
SerperIcon,
ServiceNowIcon,
SftpIcon,
ShopifyIcon,
SimilarwebIcon,
SlackIcon,
SmtpIcon,
SQSIcon,
SshIcon,
STTIcon,
StagehandIcon,
StripeIcon,
SupabaseIcon,
TavilyIcon,
TelegramIcon,
TextractIcon,
TinybirdIcon,
TranslateIcon,
TrelloIcon,
TTSIcon,
TwilioIcon,
TypeformIcon,
UpstashIcon,
VercelIcon,
VideoIcon,
WealthboxIcon,
WebflowIcon,
WhatsAppIcon,
WikipediaIcon,
WordpressIcon,
WorkdayIcon,
xIcon,
YouTubeIcon,
ZendeskIcon,
ZepIcon,
ZoomIcon,
} from '@/components/icons'
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
export const blockTypeToIconMap: Record<string, IconComponent> = {
a2a: A2AIcon,
ahrefs: AhrefsIcon,
airtable: AirtableIcon,
airweave: AirweaveIcon,
algolia: AlgoliaIcon,
amplitude: AmplitudeIcon,
apify: ApifyIcon,
apollo: ApolloIcon,
arxiv: ArxivIcon,
asana: AsanaIcon,
ashby: AshbyIcon,
attio: AttioIcon,
box: BoxCompanyIcon,
brandfetch: BrandfetchIcon,
browser_use: BrowserUseIcon,
calcom: CalComIcon,
calendly: CalendlyIcon,
circleback: CirclebackIcon,
clay: ClayIcon,
clerk: ClerkIcon,
cloudflare: CloudflareIcon,
confluence_v2: ConfluenceIcon,
cursor_v2: CursorIcon,
databricks: DatabricksIcon,
datadog: DatadogIcon,
devin: DevinIcon,
discord: DiscordIcon,
docusign: DocuSignIcon,
dropbox: DropboxIcon,
dspy: DsPyIcon,
dub: DubIcon,
duckduckgo: DuckDuckGoIcon,
dynamodb: DynamoDBIcon,
elasticsearch: ElasticsearchIcon,
elevenlabs: ElevenLabsIcon,
enrich: EnrichSoIcon,
evernote: EvernoteIcon,
exa: ExaAIIcon,
fathom: FathomIcon,
file_v3: DocumentIcon,
firecrawl: FirecrawlIcon,
fireflies_v2: FirefliesIcon,
gamma: GammaIcon,
github_v2: GithubIcon,
gitlab: GitLabIcon,
gmail_v2: GmailIcon,
gong: GongIcon,
google_ads: GoogleAdsIcon,
google_bigquery: GoogleBigQueryIcon,
google_books: GoogleBooksIcon,
google_calendar_v2: GoogleCalendarIcon,
google_contacts: GoogleContactsIcon,
google_docs: GoogleDocsIcon,
google_drive: GoogleDriveIcon,
google_forms: GoogleFormsIcon,
google_groups: GoogleGroupsIcon,
google_maps: GoogleMapsIcon,
google_meet: GoogleMeetIcon,
google_pagespeed: GooglePagespeedIcon,
google_search: GoogleIcon,
google_sheets_v2: GoogleSheetsIcon,
google_slides_v2: GoogleSlidesIcon,
google_tasks: GoogleTasksIcon,
google_translate: GoogleTranslateIcon,
google_vault: GoogleVaultIcon,
grafana: GrafanaIcon,
grain: GrainIcon,
greenhouse: GreenhouseIcon,
greptile: GreptileIcon,
hex: HexIcon,
hubspot: HubspotIcon,
huggingface: HuggingFaceIcon,
hunter: HunterIOIcon,
image_generator: ImageIcon,
imap: MailServerIcon,
incidentio: IncidentioIcon,
intercom_v2: IntercomIcon,
jina: JinaAIIcon,
jira: JiraIcon,
jira_service_management: JiraServiceManagementIcon,
kalshi_v2: KalshiIcon,
knowledge: PackageSearchIcon,
langsmith: LangsmithIcon,
lemlist: LemlistIcon,
linear: LinearIcon,
linkedin: LinkedInIcon,
linkup: LinkupIcon,
loops: LoopsIcon,
luma: LumaIcon,
mailchimp: MailchimpIcon,
mailgun: MailgunIcon,
mem0: Mem0Icon,
memory: BrainIcon,
microsoft_dataverse: MicrosoftDataverseIcon,
microsoft_excel_v2: MicrosoftExcelIcon,
microsoft_planner: MicrosoftPlannerIcon,
microsoft_teams: MicrosoftTeamsIcon,
mistral_parse_v3: MistralIcon,
mongodb: MongoDBIcon,
mysql: MySQLIcon,
neo4j: Neo4jIcon,
notion_v2: NotionIcon,
obsidian: ObsidianIcon,
okta: OktaIcon,
onedrive: MicrosoftOneDriveIcon,
onepassword: OnePasswordIcon,
openai: OpenAIIcon,
outlook: OutlookIcon,
pagerduty: PagerDutyIcon,
parallel_ai: ParallelIcon,
perplexity: PerplexityIcon,
pinecone: PineconeIcon,
pipedrive: PipedriveIcon,
polymarket: PolymarketIcon,
postgresql: PostgresIcon,
posthog: PosthogIcon,
pulse_v2: PulseIcon,
qdrant: QdrantIcon,
rds: RDSIcon,
reddit: RedditIcon,
redis: RedisIcon,
reducto_v2: ReductoIcon,
resend: ResendIcon,
revenuecat: RevenueCatIcon,
s3: S3Icon,
salesforce: SalesforceIcon,
search: SearchIcon,
sendgrid: SendgridIcon,
sentry: SentryIcon,
serper: SerperIcon,
servicenow: ServiceNowIcon,
sftp: SftpIcon,
sharepoint: MicrosoftSharepointIcon,
shopify: ShopifyIcon,
similarweb: SimilarwebIcon,
slack: SlackIcon,
smtp: SmtpIcon,
sqs: SQSIcon,
ssh: SshIcon,
stagehand: StagehandIcon,
stripe: StripeIcon,
stt_v2: STTIcon,
supabase: SupabaseIcon,
tavily: TavilyIcon,
telegram: TelegramIcon,
textract_v2: TextractIcon,
tinybird: TinybirdIcon,
translate: TranslateIcon,
trello: TrelloIcon,
tts: TTSIcon,
twilio_sms: TwilioIcon,
twilio_voice: TwilioIcon,
typeform: TypeformIcon,
upstash: UpstashIcon,
vercel: VercelIcon,
video_generator_v2: VideoIcon,
vision_v2: EyeIcon,
wealthbox: WealthboxIcon,
webflow: WebflowIcon,
whatsapp: WhatsAppIcon,
wikipedia: WikipediaIcon,
wordpress: WordpressIcon,
workday: WorkdayIcon,
x: xIcon,
youtube: YouTubeIcon,
zendesk: ZendeskIcon,
zep: ZepIcon,
zoom: ZoomIcon,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,117 @@
/**
* Curated popular workflow pairs used on both the /integrations listing page
* and individual /integrations/[slug] pages.
*
* Each pair targets specific long-tail search queries like "notion to slack automation".
* The headline and description are written to be both human-readable and keyword-rich.
*/
export interface WorkflowPair {
/** Integration name (must match `name` field in integrations.json) */
from: string
/** Integration name (must match `name` field in integrations.json) */
to: string
headline: string
description: string
}
export const POPULAR_WORKFLOWS: WorkflowPair[] = [
{
from: 'Slack',
to: 'Notion',
headline: 'Archive Slack conversations to Notion',
description:
'Capture important Slack messages as Notion pages or database entries — ideal for meeting notes, decision logs, and knowledge bases.',
},
{
from: 'Notion',
to: 'Slack',
headline: 'Notify your team from Notion',
description:
'Post Slack messages automatically when Notion pages are created or updated so the whole team stays aligned without manual check-ins.',
},
{
from: 'GitHub',
to: 'Jira',
headline: 'Link GitHub pull requests to Jira tickets',
description:
'Transition Jira issues when PRs are opened or merged, keeping your project board accurate without any manual updates.',
},
{
from: 'GitHub',
to: 'Linear',
headline: 'Sync GitHub events with Linear issues',
description:
'Create Linear issues from GitHub activity, update status on merge, and keep your engineering workflow tightly connected.',
},
{
from: 'Gmail',
to: 'Notion',
headline: 'Save incoming emails to Notion databases',
description:
'Extract structured data from Gmail and store it in Notion — ideal for lead capture, support tickets, and meeting scheduling.',
},
{
from: 'HubSpot',
to: 'Slack',
headline: 'Get HubSpot deal alerts in Slack',
description:
'Receive instant Slack notifications when HubSpot deals advance, contacts are created, or revenue milestones are hit.',
},
{
from: 'Google Sheets',
to: 'Slack',
headline: 'Send Slack messages from Google Sheets',
description:
'Watch a spreadsheet for new rows or changes, then post formatted Slack updates to keep stakeholders informed in real time.',
},
{
from: 'Salesforce',
to: 'Slack',
headline: 'Push Salesforce pipeline updates to Slack',
description:
'Alert your sales team in Slack when Salesforce opportunities advance, close, or need immediate attention.',
},
{
from: 'Airtable',
to: 'Gmail',
headline: 'Trigger Gmail from Airtable records',
description:
'Send personalised Gmail messages when Airtable records are created or updated — great for onboarding flows and follow-up sequences.',
},
{
from: 'Linear',
to: 'Slack',
headline: 'Linear issue updates in Slack',
description:
'Post Slack messages when Linear issues are created, assigned, or completed so your team is always in the loop.',
},
{
from: 'Jira',
to: 'Confluence',
headline: 'Auto-generate Confluence pages from Jira sprints',
description:
'Create Confluence documentation from Jira sprint data automatically, eliminating manual reporting at the end of every sprint.',
},
{
from: 'Google Sheets',
to: 'Notion',
headline: 'Sync Google Sheets data into Notion',
description:
'Transform spreadsheet rows into structured Notion database entries for richer documentation and cross-team project tracking.',
},
{
from: 'GitHub',
to: 'Slack',
headline: 'Get GitHub activity alerts in Slack',
description:
'Post Slack notifications for new PRs, commits, issues, or deployments so your engineering team never misses a critical event.',
},
{
from: 'HubSpot',
to: 'Gmail',
headline: 'Send personalised emails from HubSpot events',
description:
'Trigger Gmail messages when HubSpot contacts enter a lifecycle stage, ensuring timely and relevant outreach without manual effort.',
},
]

View File

@@ -0,0 +1,37 @@
// Shared types for the integrations section of the landing site.
// Mirrors the shape written by scripts/generate-docs.ts → writeIntegrationsJson().
export type AuthType = 'oauth' | 'api-key' | 'none'
export interface TriggerInfo {
id: string
name: string
description: string
}
export interface OperationInfo {
name: string
description: string
}
export interface FAQItem {
question: string
answer: string
}
export interface Integration {
type: string
slug: string
name: string
description: string
longDescription: string
bgColor: string
iconName: string
docsUrl: string
operations: OperationInfo[]
operationCount: number
triggers: TriggerInfo[]
triggerCount: number
authType: AuthType
category: string
}

View File

@@ -0,0 +1,15 @@
/**
* Utility helpers for the integrations landing pages.
* Shared across the listing grid, individual integration cards, and slug pages.
*/
/** bgColor values that are visually light and require dark icon text. */
const LIGHT_BG = new Set(['#e0e0e0', '#f5f5f5', '#ffffff', '#ececec', '#f0f0f0'])
/**
* Returns true when `bgColor` is a light color that requires dark foreground text.
* Handles gradient strings safely — they always use light foreground (white).
*/
export function isLightBg(bgColor: string): boolean {
return LIGHT_BG.has(bgColor.toLowerCase())
}

View File

@@ -0,0 +1,43 @@
import Footer from '@/app/(home)/components/footer/footer'
import Navbar from '@/app/(home)/components/navbar/navbar'
export default function IntegrationsLayout({ children }: { children: React.ReactNode }) {
const orgJsonLd = {
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Sim',
url: 'https://sim.ai',
logo: 'https://sim.ai/logo/primary/small.png',
sameAs: ['https://x.com/simdotai'],
}
const websiteJsonLd = {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'Sim',
url: 'https://sim.ai',
potentialAction: {
'@type': 'SearchAction',
target: 'https://sim.ai/search?q={search_term_string}',
'query-input': 'required name=search_term_string',
},
}
return (
<div className='dark flex min-h-screen flex-col bg-[#1C1C1C] font-[430] font-season text-[#ECECEC]'>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(orgJsonLd) }}
/>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteJsonLd) }}
/>
<header>
<Navbar />
</header>
<main className='relative flex-1'>{children}</main>
<Footer />
</div>
)
}

View File

@@ -0,0 +1,165 @@
import type { Metadata } from 'next'
import { IntegrationGrid } from './components/integration-grid'
import { blockTypeToIconMap } from './data/icon-mapping'
import integrations from './data/integrations.json'
import { POPULAR_WORKFLOWS } from './data/popular-workflows'
import type { Integration } from './data/types'
const allIntegrations = integrations as Integration[]
const INTEGRATION_COUNT = allIntegrations.length
/**
* Unique integration names that appear in popular workflow pairs.
* Used for metadata keywords so they stay in sync automatically.
*/
const TOP_NAMES = [...new Set(POPULAR_WORKFLOWS.flatMap((p) => [p.from, p.to]))].slice(0, 6)
export const metadata: Metadata = {
title: 'Integrations',
description: `Connect ${INTEGRATION_COUNT}+ apps and services with Sim's AI workflow automation. Build intelligent pipelines with ${TOP_NAMES.join(', ')}, and more.`,
keywords: [
'workflow automation integrations',
'AI workflow automation',
'no-code automation',
...TOP_NAMES.flatMap((n) => [`${n} integration`, `${n} automation`]),
...allIntegrations.slice(0, 20).map((i) => `${i.name} automation`),
],
openGraph: {
title: 'Integrations for AI Workflow Automation | Sim',
description: `Connect ${INTEGRATION_COUNT}+ apps with Sim. Build AI-powered pipelines that link ${TOP_NAMES.join(', ')}, and every tool your team uses.`,
url: 'https://sim.ai/integrations',
type: 'website',
images: [{ url: 'https://sim.ai/opengraph-image.png', width: 1200, height: 630 }],
},
twitter: {
card: 'summary_large_image',
title: 'Integrations | Sim',
description: `Connect ${INTEGRATION_COUNT}+ apps with Sim's AI workflow automation.`,
},
alternates: { canonical: 'https://sim.ai/integrations' },
}
export default function IntegrationsPage() {
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://sim.ai' },
{
'@type': 'ListItem',
position: 2,
name: 'Integrations',
item: 'https://sim.ai/integrations',
},
],
}
const itemListJsonLd = {
'@context': 'https://schema.org',
'@type': 'ItemList',
name: 'Sim AI Workflow Integrations',
description: `Complete list of ${INTEGRATION_COUNT}+ integrations available in Sim for building AI-powered workflow automation.`,
url: 'https://sim.ai/integrations',
numberOfItems: INTEGRATION_COUNT,
itemListElement: allIntegrations.map((integration, index) => ({
'@type': 'ListItem',
position: index + 1,
item: {
'@type': 'SoftwareApplication',
name: integration.name,
description: integration.description,
url: `https://sim.ai/integrations/${integration.slug}`,
applicationCategory: 'BusinessApplication',
featureList: integration.operations.map((o) => o.name),
},
})),
}
return (
<>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
/>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(itemListJsonLd) }}
/>
<div className='mx-auto max-w-[1200px] px-6 py-16 sm:px-8 md:px-12'>
{/* Hero */}
<section aria-labelledby='integrations-heading' className='mb-16'>
<h1
id='integrations-heading'
className='mb-4 font-[500] text-[#ECECEC] text-[40px] leading-tight sm:text-[56px]'
>
Integrations
</h1>
<p className='max-w-[640px] text-[#999] text-[18px] leading-relaxed'>
Connect every tool your team uses. Build AI-powered workflows that automate tasks across{' '}
{TOP_NAMES.slice(0, 4).map((name, i, arr) => {
const integration = allIntegrations.find((int) => int.name === name)
const Icon = integration ? blockTypeToIconMap[integration.type] : undefined
return (
<span key={name} className='inline-flex items-center gap-[5px]'>
{Icon && (
<span
aria-hidden='true'
className='inline-flex shrink-0'
style={{ opacity: 0.65 }}
>
<Icon className='h-[0.85em] w-[0.85em]' />
</span>
)}
{name}
{i < arr.length - 1 ? ', ' : ''}
</span>
)
})}
{' and more.'}
</p>
</section>
{/* Searchable grid — client component */}
<section aria-labelledby='all-integrations-heading'>
<h2 id='all-integrations-heading' className='mb-8 font-[500] text-[#ECECEC] text-[24px]'>
All Integrations
</h2>
<IntegrationGrid integrations={allIntegrations} />
</section>
{/* Integration request */}
<div className='mt-16 flex flex-col items-start gap-3 border-[#2A2A2A] border-t pt-10 sm:flex-row sm:items-center sm:justify-between'>
<div>
<p className='font-[500] text-[#ECECEC] text-[15px]'>
Don&apos;t see the integration you need?
</p>
<p className='mt-0.5 text-[#555] text-[13px]'>
Let us know and we&apos;ll prioritize it.
</p>
</div>
<a
href='https://github.com/simstudioai/sim/issues/new?labels=integration+request&template=integration_request.md'
target='_blank'
rel='noopener noreferrer'
className='inline-flex h-[32px] shrink-0 items-center gap-[6px] rounded-[5px] border border-[#3d3d3d] px-[10px] font-[430] font-season text-[#ECECEC] text-[14px] transition-colors hover:bg-[#2A2A2A]'
>
Request an integration
<svg
aria-hidden='true'
className='h-3 w-3'
fill='none'
stroke='currentColor'
strokeWidth={2}
viewBox='0 0 24 24'
>
<path d='M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6' />
<polyline points='15 3 21 3 21 9' />
<line x1='10' x2='21' y1='14' y2='3' />
</svg>
</a>
</div>
</div>
</>
)
}

View File

@@ -7,6 +7,7 @@ import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
@@ -38,7 +39,13 @@ function toCredentialResponse(
scope: string | null
) {
const storedScope = scope?.trim()
const scopes = storedScope ? storedScope.split(/[\s,]+/).filter(Boolean) : []
// Some providers (e.g. Box) don't return scopes in their token response,
// so the DB column stays empty. Fall back to the configured scopes for
// the provider so the credential-selector doesn't show a false
// "Additional permissions required" banner.
const scopes = storedScope
? storedScope.split(/[\s,]+/).filter(Boolean)
: getCanonicalScopesForProvider(providerId)
const [_, featureType = 'default'] = providerId.split('-')
return {

View File

@@ -142,7 +142,7 @@ export async function POST(request: NextRequest) {
quantity: currentQuantity,
},
],
proration_behavior: 'create_prorations',
proration_behavior: 'always_invoice',
})
}

View File

@@ -102,7 +102,7 @@ async function handleLocalFile(filename: string, userId: string): Promise<NextRe
throw new FileNotFoundError(`File not found: ${filename}`)
}
const filePath = findLocalFile(filename)
const filePath = await findLocalFile(filename)
if (!filePath) {
throw new FileNotFoundError(`File not found: ${filename}`)
@@ -228,7 +228,7 @@ async function handleCloudProxyPublic(
async function handleLocalFilePublic(filename: string): Promise<NextResponse> {
try {
const filePath = findLocalFile(filename)
const filePath = await findLocalFile(filename)
if (!filePath) {
throw new FileNotFoundError(`File not found: ${filename}`)

View File

@@ -75,7 +75,7 @@ export async function POST(request: NextRequest) {
const uploadResults = []
for (const file of files) {
const originalName = file.name || 'untitled'
const originalName = file.name || 'untitled.md'
if (!validateFileExtension(originalName)) {
const extension = originalName.split('.').pop()?.toLowerCase() || 'unknown'

View File

@@ -331,7 +331,7 @@ describe('extractFilename', () => {
describe('findLocalFile - Path Traversal Security Tests', () => {
describe('path traversal attack prevention', () => {
it.concurrent('should reject classic path traversal attacks', () => {
it.concurrent('should reject classic path traversal attacks', async () => {
const maliciousInputs = [
'../../../etc/passwd',
'..\\..\\..\\windows\\system32\\config\\sam',
@@ -340,35 +340,35 @@ describe('findLocalFile - Path Traversal Security Tests', () => {
'..\\config.ini',
]
maliciousInputs.forEach((input) => {
const result = findLocalFile(input)
for (const input of maliciousInputs) {
const result = await findLocalFile(input)
expect(result).toBeNull()
})
}
})
it.concurrent('should reject encoded path traversal attempts', () => {
it.concurrent('should reject encoded path traversal attempts', async () => {
const encodedInputs = [
'%2e%2e%2f%2e%2e%2f%65%74%63%2f%70%61%73%73%77%64', // ../../../etc/passwd
'..%2f..%2fetc%2fpasswd',
'..%5c..%5cconfig.ini',
]
encodedInputs.forEach((input) => {
const result = findLocalFile(input)
for (const input of encodedInputs) {
const result = await findLocalFile(input)
expect(result).toBeNull()
})
}
})
it.concurrent('should reject mixed path separators', () => {
it.concurrent('should reject mixed path separators', async () => {
const mixedInputs = ['../..\\config.txt', '..\\../secret.ini', '/..\\..\\system32']
mixedInputs.forEach((input) => {
const result = findLocalFile(input)
for (const input of mixedInputs) {
const result = await findLocalFile(input)
expect(result).toBeNull()
})
}
})
it.concurrent('should reject filenames with dangerous characters', () => {
it.concurrent('should reject filenames with dangerous characters', async () => {
const dangerousInputs = [
'file:with:colons.txt',
'file|with|pipes.txt',
@@ -376,43 +376,45 @@ describe('findLocalFile - Path Traversal Security Tests', () => {
'file*with*asterisks.txt',
]
dangerousInputs.forEach((input) => {
const result = findLocalFile(input)
for (const input of dangerousInputs) {
const result = await findLocalFile(input)
expect(result).toBeNull()
})
}
})
it.concurrent('should reject null and empty inputs', () => {
expect(findLocalFile('')).toBeNull()
expect(findLocalFile(' ')).toBeNull()
expect(findLocalFile('\t\n')).toBeNull()
it.concurrent('should reject null and empty inputs', async () => {
expect(await findLocalFile('')).toBeNull()
expect(await findLocalFile(' ')).toBeNull()
expect(await findLocalFile('\t\n')).toBeNull()
})
it.concurrent('should reject filenames that become empty after sanitization', () => {
it.concurrent('should reject filenames that become empty after sanitization', async () => {
const emptyAfterSanitization = ['../..', '..\\..\\', '////', '....', '..']
emptyAfterSanitization.forEach((input) => {
const result = findLocalFile(input)
for (const input of emptyAfterSanitization) {
const result = await findLocalFile(input)
expect(result).toBeNull()
})
}
})
})
describe('security validation passes for legitimate files', () => {
it.concurrent('should accept properly formatted filenames without throwing errors', () => {
const legitimateInputs = [
'document.pdf',
'image.png',
'data.csv',
'report-2024.doc',
'file_with_underscores.txt',
'file-with-dashes.json',
]
it.concurrent(
'should accept properly formatted filenames without throwing errors',
async () => {
const legitimateInputs = [
'document.pdf',
'image.png',
'data.csv',
'report-2024.doc',
'file_with_underscores.txt',
'file-with-dashes.json',
]
legitimateInputs.forEach((input) => {
// Should not throw security errors for legitimate filenames
expect(() => findLocalFile(input)).not.toThrow()
})
})
for (const input of legitimateInputs) {
await expect(findLocalFile(input)).resolves.toBeDefined()
}
}
)
})
})

View File

@@ -1,8 +1,5 @@
import { existsSync } from 'fs'
import path from 'path'
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { UPLOAD_DIR } from '@/lib/uploads/config'
import { sanitizeFileKey } from '@/lib/uploads/utils/file-utils'
const logger = createLogger('FilesUtils')
@@ -123,76 +120,29 @@ export function extractFilename(path: string): string {
return filename
}
function sanitizeFilename(filename: string): string {
if (!filename || typeof filename !== 'string') {
throw new Error('Invalid filename provided')
}
if (!filename.includes('/')) {
throw new Error('File key must include a context prefix (e.g., kb/, workspace/, execution/)')
}
const segments = filename.split('/')
const sanitizedSegments = segments.map((segment) => {
if (segment === '..' || segment === '.') {
throw new Error('Path traversal detected')
}
const sanitized = segment.replace(/\.\./g, '').replace(/[\\]/g, '').replace(/^\./g, '').trim()
if (!sanitized) {
throw new Error('Invalid or empty path segment after sanitization')
}
if (
sanitized.includes(':') ||
sanitized.includes('|') ||
sanitized.includes('?') ||
sanitized.includes('*') ||
sanitized.includes('\x00') ||
/[\x00-\x1F\x7F]/.test(sanitized)
) {
throw new Error('Path segment contains invalid characters')
}
return sanitized
})
return sanitizedSegments.join(path.sep)
}
export function findLocalFile(filename: string): string | null {
export async function findLocalFile(filename: string): Promise<string | null> {
try {
const sanitizedFilename = sanitizeFileKey(filename)
// Reject if sanitized filename is empty or only contains path separators/dots
if (!sanitizedFilename || !sanitizedFilename.trim() || /^[/\\.\s]+$/.test(sanitizedFilename)) {
return null
}
const possiblePaths = [
path.join(UPLOAD_DIR, sanitizedFilename),
path.join(process.cwd(), 'uploads', sanitizedFilename),
]
const { existsSync } = await import('fs')
const path = await import('path')
const { UPLOAD_DIR_SERVER } = await import('@/lib/uploads/core/setup.server')
for (const filePath of possiblePaths) {
const resolvedPath = path.resolve(filePath)
const allowedDirs = [path.resolve(UPLOAD_DIR), path.resolve(process.cwd(), 'uploads')]
const resolvedPath = path.join(UPLOAD_DIR_SERVER, sanitizedFilename)
// Must be within allowed directory but NOT the directory itself
const isWithinAllowedDir = allowedDirs.some(
(allowedDir) =>
resolvedPath.startsWith(allowedDir + path.sep) && resolvedPath !== allowedDir
)
if (
!resolvedPath.startsWith(UPLOAD_DIR_SERVER + path.sep) ||
resolvedPath === UPLOAD_DIR_SERVER
) {
return null
}
if (!isWithinAllowedDir) {
continue
}
if (existsSync(resolvedPath)) {
return resolvedPath
}
if (existsSync(resolvedPath)) {
return resolvedPath
}
return null

View File

@@ -0,0 +1,248 @@
import { randomUUID } from 'crypto'
import { db } from '@sim/db'
import { document } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import {
createDocumentRecords,
deleteDocument,
getProcessingConfig,
processDocumentsWithQueue,
} from '@/lib/knowledge/documents/service'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
const logger = createLogger('DocumentUpsertAPI')
const UpsertDocumentSchema = z.object({
documentId: z.string().optional(),
filename: z.string().min(1, 'Filename is required'),
fileUrl: z.string().min(1, 'File URL is required'),
fileSize: z.number().min(1, 'File size must be greater than 0'),
mimeType: z.string().min(1, 'MIME type is required'),
documentTagsData: z.string().optional(),
processingOptions: z.object({
chunkSize: z.number().min(100).max(4000),
minCharactersPerChunk: z.number().min(1).max(2000),
recipe: z.string(),
lang: z.string(),
chunkOverlap: z.number().min(0).max(500),
}),
workflowId: z.string().optional(),
})
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = randomUUID().slice(0, 8)
const { id: knowledgeBaseId } = await params
try {
const body = await req.json()
logger.info(`[${requestId}] Knowledge base document upsert request`, {
knowledgeBaseId,
hasDocumentId: !!body.documentId,
filename: body.filename,
})
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Authentication failed: ${auth.error || 'Unauthorized'}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
const validatedData = UpsertDocumentSchema.parse(body)
if (validatedData.workflowId) {
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId: validatedData.workflowId,
userId,
action: 'write',
})
if (!authorization.allowed) {
return NextResponse.json(
{ error: authorization.message || 'Access denied' },
{ status: authorization.status }
)
}
}
const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, userId)
if (!accessCheck.hasAccess) {
if ('notFound' in accessCheck && accessCheck.notFound) {
logger.warn(`[${requestId}] Knowledge base not found: ${knowledgeBaseId}`)
return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
}
logger.warn(
`[${requestId}] User ${userId} attempted to upsert document in unauthorized knowledge base ${knowledgeBaseId}`
)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
let existingDocumentId: string | null = null
let isUpdate = false
if (validatedData.documentId) {
const existingDoc = await db
.select({ id: document.id })
.from(document)
.where(
and(
eq(document.id, validatedData.documentId),
eq(document.knowledgeBaseId, knowledgeBaseId),
isNull(document.deletedAt)
)
)
.limit(1)
if (existingDoc.length > 0) {
existingDocumentId = existingDoc[0].id
}
} else {
const docsByFilename = await db
.select({ id: document.id })
.from(document)
.where(
and(
eq(document.filename, validatedData.filename),
eq(document.knowledgeBaseId, knowledgeBaseId),
isNull(document.deletedAt)
)
)
.limit(1)
if (docsByFilename.length > 0) {
existingDocumentId = docsByFilename[0].id
}
}
if (existingDocumentId) {
isUpdate = true
logger.info(
`[${requestId}] Found existing document ${existingDocumentId}, creating replacement before deleting old`
)
}
const createdDocuments = await createDocumentRecords(
[
{
filename: validatedData.filename,
fileUrl: validatedData.fileUrl,
fileSize: validatedData.fileSize,
mimeType: validatedData.mimeType,
...(validatedData.documentTagsData && {
documentTagsData: validatedData.documentTagsData,
}),
},
],
knowledgeBaseId,
requestId
)
const firstDocument = createdDocuments[0]
if (!firstDocument) {
logger.error(`[${requestId}] createDocumentRecords returned empty array unexpectedly`)
return NextResponse.json({ error: 'Failed to create document record' }, { status: 500 })
}
if (existingDocumentId) {
try {
await deleteDocument(existingDocumentId, requestId)
} catch (deleteError) {
logger.error(
`[${requestId}] Failed to delete old document ${existingDocumentId}, rolling back new record`,
deleteError
)
await deleteDocument(firstDocument.documentId, requestId).catch(() => {})
return NextResponse.json({ error: 'Failed to replace existing document' }, { status: 500 })
}
}
processDocumentsWithQueue(
createdDocuments,
knowledgeBaseId,
validatedData.processingOptions,
requestId
).catch((error: unknown) => {
logger.error(`[${requestId}] Critical error in document processing pipeline:`, error)
})
try {
const { PlatformEvents } = await import('@/lib/core/telemetry')
PlatformEvents.knowledgeBaseDocumentsUploaded({
knowledgeBaseId,
documentsCount: 1,
uploadType: 'single',
chunkSize: validatedData.processingOptions.chunkSize,
recipe: validatedData.processingOptions.recipe,
})
} catch (_e) {
// Silently fail
}
recordAudit({
workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null,
actorId: userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: isUpdate ? AuditAction.DOCUMENT_UPDATED : AuditAction.DOCUMENT_UPLOADED,
resourceType: AuditResourceType.DOCUMENT,
resourceId: knowledgeBaseId,
resourceName: validatedData.filename,
description: isUpdate
? `Upserted (replaced) document "${validatedData.filename}" in knowledge base "${knowledgeBaseId}"`
: `Upserted (created) document "${validatedData.filename}" in knowledge base "${knowledgeBaseId}"`,
metadata: {
fileName: validatedData.filename,
previousDocumentId: existingDocumentId,
isUpdate,
},
request: req,
})
return NextResponse.json({
success: true,
data: {
documentsCreated: [
{
documentId: firstDocument.documentId,
filename: firstDocument.filename,
status: 'pending',
},
],
isUpdate,
previousDocumentId: existingDocumentId,
processingMethod: 'background',
processingConfig: {
maxConcurrentDocuments: getProcessingConfig().maxConcurrentDocuments,
batchSize: getProcessingConfig().batchSize,
},
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid upsert request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error upserting document`, error)
const errorMessage = error instanceof Error ? error.message : 'Failed to upsert document'
const isStorageLimitError =
errorMessage.includes('Storage limit exceeded') || errorMessage.includes('storage limit')
const isMissingKnowledgeBase = errorMessage === 'Knowledge base not found'
return NextResponse.json(
{ error: errorMessage },
{ status: isMissingKnowledgeBase ? 404 : isStorageLimitError ? 413 : 500 }
)
}
}

View File

@@ -279,6 +279,7 @@ export async function POST(req: NextRequest) {
role: 'assistant' as const,
content: result.content,
timestamp: new Date().toISOString(),
...(result.requestId ? { requestId: result.requestId } : {}),
}
if (result.toolCalls.length > 0) {
assistantMessage.toolCalls = result.toolCalls

View File

@@ -161,7 +161,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
quantity: newSeatCount,
},
],
proration_behavior: 'create_prorations', // Stripe's default - charge/credit immediately
proration_behavior: 'always_invoice',
}
)
@@ -213,7 +213,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
oldSeats: currentSeats,
newSeats: newSeatCount,
updatedBy: session.user.id,
prorationBehavior: 'create_prorations',
prorationBehavior: 'always_invoice',
})
return NextResponse.json({

View File

@@ -0,0 +1,140 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { FileInputSchema } from '@/lib/uploads/utils/file-schemas'
import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
export const dynamic = 'force-dynamic'
const logger = createLogger('BoxUploadAPI')
const BoxUploadSchema = z.object({
accessToken: z.string().min(1, 'Access token is required'),
parentFolderId: z.string().min(1, 'Parent folder ID is required'),
file: FileInputSchema.optional().nullable(),
fileContent: z.string().optional().nullable(),
fileName: z.string().optional().nullable(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized Box upload attempt: ${authResult.error}`)
return NextResponse.json(
{ success: false, error: authResult.error || 'Authentication required' },
{ status: 401 }
)
}
logger.info(`[${requestId}] Authenticated Box upload request via ${authResult.authType}`)
const body = await request.json()
const validatedData = BoxUploadSchema.parse(body)
let fileBuffer: Buffer
let fileName: string
if (validatedData.file) {
const userFiles = processFilesToUserFiles(
[validatedData.file as RawFileInput],
requestId,
logger
)
if (userFiles.length === 0) {
return NextResponse.json({ success: false, error: 'Invalid file input' }, { status: 400 })
}
const userFile = userFiles[0]
logger.info(`[${requestId}] Downloading file: ${userFile.name} (${userFile.size} bytes)`)
fileBuffer = await downloadFileFromStorage(userFile, requestId, logger)
fileName = validatedData.fileName || userFile.name
} else if (validatedData.fileContent) {
logger.info(`[${requestId}] Using legacy base64 content input`)
fileBuffer = Buffer.from(validatedData.fileContent, 'base64')
fileName = validatedData.fileName || 'file'
} else {
return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 })
}
logger.info(
`[${requestId}] Uploading to Box folder ${validatedData.parentFolderId}: ${fileName} (${fileBuffer.length} bytes)`
)
const attributes = JSON.stringify({
name: fileName,
parent: { id: validatedData.parentFolderId },
})
const formData = new FormData()
formData.append('attributes', attributes)
formData.append(
'file',
new Blob([new Uint8Array(fileBuffer)], { type: 'application/octet-stream' }),
fileName
)
const response = await fetch('https://upload.box.com/api/2.0/files/content', {
method: 'POST',
headers: {
Authorization: `Bearer ${validatedData.accessToken}`,
},
body: formData,
})
const data = await response.json()
if (!response.ok) {
const errorMessage = data.message || 'Failed to upload file'
logger.error(`[${requestId}] Box API error:`, { status: response.status, data })
return NextResponse.json({ success: false, error: errorMessage }, { status: response.status })
}
const file = data.entries?.[0]
if (!file) {
return NextResponse.json(
{ success: false, error: 'No file returned in upload response' },
{ status: 500 }
)
}
logger.info(`[${requestId}] File uploaded successfully: ${file.name} (ID: ${file.id})`)
return NextResponse.json({
success: true,
output: {
id: file.id ?? '',
name: file.name ?? '',
size: file.size ?? 0,
sha1: file.sha1 ?? null,
createdAt: file.created_at ?? null,
modifiedAt: file.modified_at ?? null,
parentId: file.parent?.id ?? null,
parentName: file.parent?.name ?? null,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Validation error:`, error.errors)
return NextResponse.json(
{ success: false, error: error.errors[0]?.message || 'Validation failed' },
{ status: 400 }
)
}
logger.error(`[${requestId}] Unexpected error:`, error)
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,466 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { FileInputSchema } from '@/lib/uploads/utils/file-schemas'
import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
const logger = createLogger('DocuSignAPI')
interface DocuSignAccountInfo {
accountId: string
baseUri: string
}
/**
* Resolves the user's DocuSign account info from their access token
* by calling the DocuSign userinfo endpoint.
*/
async function resolveAccount(accessToken: string): Promise<DocuSignAccountInfo> {
const response = await fetch('https://account-d.docusign.com/oauth/userinfo', {
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Failed to resolve DocuSign account', {
status: response.status,
error: errorText,
})
throw new Error(`Failed to resolve DocuSign account: ${response.status}`)
}
const data = await response.json()
const accounts = data.accounts ?? []
const defaultAccount = accounts.find((a: { is_default: boolean }) => a.is_default) ?? accounts[0]
if (!defaultAccount) {
throw new Error('No DocuSign accounts found for this user')
}
const baseUri = defaultAccount.base_uri
if (!baseUri) {
throw new Error('DocuSign account is missing base_uri')
}
return {
accountId: defaultAccount.account_id,
baseUri,
}
}
export async function POST(request: NextRequest) {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { accessToken, operation, ...params } = body
if (!accessToken) {
return NextResponse.json({ success: false, error: 'Access token is required' }, { status: 400 })
}
if (!operation) {
return NextResponse.json({ success: false, error: 'Operation is required' }, { status: 400 })
}
try {
const account = await resolveAccount(accessToken)
const apiBase = `${account.baseUri}/restapi/v2.1/accounts/${account.accountId}`
const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
}
switch (operation) {
case 'send_envelope':
return await handleSendEnvelope(apiBase, headers, params)
case 'create_from_template':
return await handleCreateFromTemplate(apiBase, headers, params)
case 'get_envelope':
return await handleGetEnvelope(apiBase, headers, params)
case 'list_envelopes':
return await handleListEnvelopes(apiBase, headers, params)
case 'void_envelope':
return await handleVoidEnvelope(apiBase, headers, params)
case 'download_document':
return await handleDownloadDocument(apiBase, headers, params)
case 'list_templates':
return await handleListTemplates(apiBase, headers, params)
case 'list_recipients':
return await handleListRecipients(apiBase, headers, params)
default:
return NextResponse.json(
{ success: false, error: `Unknown operation: ${operation}` },
{ status: 400 }
)
}
} catch (error) {
logger.error('DocuSign API error', { operation, error })
const message = error instanceof Error ? error.message : 'Internal server error'
return NextResponse.json({ success: false, error: message }, { status: 500 })
}
}
async function handleSendEnvelope(
apiBase: string,
headers: Record<string, string>,
params: Record<string, unknown>
) {
const { signerEmail, signerName, emailSubject, emailBody, ccEmail, ccName, file, status } = params
if (!signerEmail || !signerName || !emailSubject) {
return NextResponse.json(
{ success: false, error: 'signerEmail, signerName, and emailSubject are required' },
{ status: 400 }
)
}
let documentBase64 = ''
let documentName = 'document.pdf'
if (file) {
try {
const parsed = FileInputSchema.parse(file)
const userFiles = processFilesToUserFiles([parsed as RawFileInput], 'docusign-send', logger)
if (userFiles.length > 0) {
const userFile = userFiles[0]
const buffer = await downloadFileFromStorage(userFile, 'docusign-send', logger)
documentBase64 = buffer.toString('base64')
documentName = userFile.name
}
} catch (fileError) {
logger.error('Failed to process file for DocuSign envelope', { fileError })
return NextResponse.json(
{ success: false, error: 'Failed to process uploaded file' },
{ status: 400 }
)
}
}
const envelopeBody: Record<string, unknown> = {
emailSubject,
status: (status as string) || 'sent',
recipients: {
signers: [
{
email: signerEmail,
name: signerName,
recipientId: '1',
routingOrder: '1',
tabs: {
signHereTabs: [
{
anchorString: '/sig1/',
anchorUnits: 'pixels',
anchorXOffset: '0',
anchorYOffset: '0',
},
],
dateSignedTabs: [
{
anchorString: '/date1/',
anchorUnits: 'pixels',
anchorXOffset: '0',
anchorYOffset: '0',
},
],
},
},
],
carbonCopies: ccEmail
? [
{
email: ccEmail,
name: ccName || (ccEmail as string),
recipientId: '2',
routingOrder: '2',
},
]
: [],
},
}
if (emailBody) {
envelopeBody.emailBlurb = emailBody
}
if (documentBase64) {
envelopeBody.documents = [
{
documentBase64,
name: documentName,
fileExtension: documentName.split('.').pop() || 'pdf',
documentId: '1',
},
]
} else if (((status as string) || 'sent') === 'sent') {
return NextResponse.json(
{ success: false, error: 'A document file is required to send an envelope' },
{ status: 400 }
)
}
const response = await fetch(`${apiBase}/envelopes`, {
method: 'POST',
headers,
body: JSON.stringify(envelopeBody),
})
const data = await response.json()
if (!response.ok) {
logger.error('DocuSign send envelope failed', { data, status: response.status })
return NextResponse.json(
{ success: false, error: data.message || data.errorCode || 'Failed to send envelope' },
{ status: response.status }
)
}
return NextResponse.json(data)
}
async function handleCreateFromTemplate(
apiBase: string,
headers: Record<string, string>,
params: Record<string, unknown>
) {
const { templateId, emailSubject, emailBody, templateRoles, status } = params
if (!templateId) {
return NextResponse.json({ success: false, error: 'templateId is required' }, { status: 400 })
}
let parsedRoles: unknown[] = []
if (templateRoles) {
if (typeof templateRoles === 'string') {
try {
parsedRoles = JSON.parse(templateRoles)
} catch {
return NextResponse.json(
{ success: false, error: 'Invalid JSON for templateRoles' },
{ status: 400 }
)
}
} else if (Array.isArray(templateRoles)) {
parsedRoles = templateRoles
}
}
const envelopeBody: Record<string, unknown> = {
templateId,
status: (status as string) || 'sent',
templateRoles: parsedRoles,
}
if (emailSubject) envelopeBody.emailSubject = emailSubject
if (emailBody) envelopeBody.emailBlurb = emailBody
const response = await fetch(`${apiBase}/envelopes`, {
method: 'POST',
headers,
body: JSON.stringify(envelopeBody),
})
const data = await response.json()
if (!response.ok) {
logger.error('DocuSign create from template failed', { data, status: response.status })
return NextResponse.json(
{
success: false,
error: data.message || data.errorCode || 'Failed to create envelope from template',
},
{ status: response.status }
)
}
return NextResponse.json(data)
}
async function handleGetEnvelope(
apiBase: string,
headers: Record<string, string>,
params: Record<string, unknown>
) {
const { envelopeId } = params
if (!envelopeId) {
return NextResponse.json({ success: false, error: 'envelopeId is required' }, { status: 400 })
}
const response = await fetch(
`${apiBase}/envelopes/${(envelopeId as string).trim()}?include=recipients,documents`,
{ headers }
)
const data = await response.json()
if (!response.ok) {
return NextResponse.json(
{ success: false, error: data.message || data.errorCode || 'Failed to get envelope' },
{ status: response.status }
)
}
return NextResponse.json(data)
}
async function handleListEnvelopes(
apiBase: string,
headers: Record<string, string>,
params: Record<string, unknown>
) {
const queryParams = new URLSearchParams()
const fromDate = params.fromDate as string | undefined
if (fromDate) {
queryParams.append('from_date', fromDate)
} else {
const thirtyDaysAgo = new Date()
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
queryParams.append('from_date', thirtyDaysAgo.toISOString())
}
if (params.toDate) queryParams.append('to_date', params.toDate as string)
if (params.envelopeStatus) queryParams.append('status', params.envelopeStatus as string)
if (params.searchText) queryParams.append('search_text', params.searchText as string)
if (params.count) queryParams.append('count', params.count as string)
const response = await fetch(`${apiBase}/envelopes?${queryParams}`, { headers })
const data = await response.json()
if (!response.ok) {
return NextResponse.json(
{ success: false, error: data.message || data.errorCode || 'Failed to list envelopes' },
{ status: response.status }
)
}
return NextResponse.json(data)
}
async function handleVoidEnvelope(
apiBase: string,
headers: Record<string, string>,
params: Record<string, unknown>
) {
const { envelopeId, voidedReason } = params
if (!envelopeId) {
return NextResponse.json({ success: false, error: 'envelopeId is required' }, { status: 400 })
}
if (!voidedReason) {
return NextResponse.json({ success: false, error: 'voidedReason is required' }, { status: 400 })
}
const response = await fetch(`${apiBase}/envelopes/${(envelopeId as string).trim()}`, {
method: 'PUT',
headers,
body: JSON.stringify({ status: 'voided', voidedReason }),
})
const data = await response.json()
if (!response.ok) {
return NextResponse.json(
{ success: false, error: data.message || data.errorCode || 'Failed to void envelope' },
{ status: response.status }
)
}
return NextResponse.json({ envelopeId, status: 'voided' })
}
async function handleDownloadDocument(
apiBase: string,
headers: Record<string, string>,
params: Record<string, unknown>
) {
const { envelopeId, documentId } = params
if (!envelopeId) {
return NextResponse.json({ success: false, error: 'envelopeId is required' }, { status: 400 })
}
const docId = (documentId as string) || 'combined'
const response = await fetch(
`${apiBase}/envelopes/${(envelopeId as string).trim()}/documents/${docId}`,
{
headers: { Authorization: headers.Authorization },
}
)
if (!response.ok) {
let errorText = ''
try {
errorText = await response.text()
} catch {
// ignore
}
return NextResponse.json(
{ success: false, error: `Failed to download document: ${response.status} ${errorText}` },
{ status: response.status }
)
}
const contentType = response.headers.get('content-type') || 'application/pdf'
const contentDisposition = response.headers.get('content-disposition') || ''
let fileName = `document-${docId}.pdf`
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
if (filenameMatch) {
fileName = filenameMatch[1].replace(/['"]/g, '')
}
const buffer = Buffer.from(await response.arrayBuffer())
const base64Content = buffer.toString('base64')
return NextResponse.json({ base64Content, mimeType: contentType, fileName })
}
async function handleListTemplates(
apiBase: string,
headers: Record<string, string>,
params: Record<string, unknown>
) {
const queryParams = new URLSearchParams()
if (params.searchText) queryParams.append('search_text', params.searchText as string)
if (params.count) queryParams.append('count', params.count as string)
const queryString = queryParams.toString()
const url = queryString ? `${apiBase}/templates?${queryString}` : `${apiBase}/templates`
const response = await fetch(url, { headers })
const data = await response.json()
if (!response.ok) {
return NextResponse.json(
{ success: false, error: data.message || data.errorCode || 'Failed to list templates' },
{ status: response.status }
)
}
return NextResponse.json(data)
}
async function handleListRecipients(
apiBase: string,
headers: Record<string, string>,
params: Record<string, unknown>
) {
const { envelopeId } = params
if (!envelopeId) {
return NextResponse.json({ success: false, error: 'envelopeId is required' }, { status: 400 })
}
const response = await fetch(`${apiBase}/envelopes/${(envelopeId as string).trim()}/recipients`, {
headers,
})
const data = await response.json()
if (!response.ok) {
return NextResponse.json(
{ success: false, error: data.message || data.errorCode || 'Failed to list recipients' },
{ status: response.status }
)
}
return NextResponse.json(data)
}

View File

@@ -0,0 +1,67 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { createWorkdaySoapClient, extractRefId, wdRef } from '@/tools/workday/soap'
export const dynamic = 'force-dynamic'
const logger = createLogger('WorkdayAssignOnboardingAPI')
const RequestSchema = z.object({
tenantUrl: z.string().min(1),
tenant: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
workerId: z.string().min(1),
onboardingPlanId: z.string().min(1),
actionEventId: z.string().min(1),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const data = RequestSchema.parse(body)
const client = await createWorkdaySoapClient(
data.tenantUrl,
data.tenant,
'humanResources',
data.username,
data.password
)
const [result] = await client.Put_Onboarding_Plan_AssignmentAsync({
Onboarding_Plan_Assignment_Data: {
Onboarding_Plan_Reference: wdRef('Onboarding_Plan_ID', data.onboardingPlanId),
Person_Reference: wdRef('WID', data.workerId),
Action_Event_Reference: wdRef('Background_Check_ID', data.actionEventId),
Assignment_Effective_Moment: new Date().toISOString(),
Active: true,
},
})
return NextResponse.json({
success: true,
output: {
assignmentId: extractRefId(result?.Onboarding_Plan_Assignment_Reference),
workerId: data.workerId,
planId: data.onboardingPlanId,
},
})
} catch (error) {
logger.error(`[${requestId}] Workday assign onboarding failed`, { error })
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,94 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { createWorkdaySoapClient, extractRefId, wdRef } from '@/tools/workday/soap'
export const dynamic = 'force-dynamic'
const logger = createLogger('WorkdayChangeJobAPI')
const RequestSchema = z.object({
tenantUrl: z.string().min(1),
tenant: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
workerId: z.string().min(1),
effectiveDate: z.string().min(1),
newPositionId: z.string().optional(),
newJobProfileId: z.string().optional(),
newLocationId: z.string().optional(),
newSupervisoryOrgId: z.string().optional(),
reason: z.string().min(1, 'Reason is required for job changes'),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const data = RequestSchema.parse(body)
const changeJobDetailData: Record<string, unknown> = {
Reason_Reference: wdRef('Change_Job_Subcategory_ID', data.reason),
}
if (data.newPositionId) {
changeJobDetailData.Position_Reference = wdRef('Position_ID', data.newPositionId)
}
if (data.newJobProfileId) {
changeJobDetailData.Job_Profile_Reference = wdRef('Job_Profile_ID', data.newJobProfileId)
}
if (data.newLocationId) {
changeJobDetailData.Location_Reference = wdRef('Location_ID', data.newLocationId)
}
if (data.newSupervisoryOrgId) {
changeJobDetailData.Supervisory_Organization_Reference = wdRef(
'Supervisory_Organization_ID',
data.newSupervisoryOrgId
)
}
const client = await createWorkdaySoapClient(
data.tenantUrl,
data.tenant,
'staffing',
data.username,
data.password
)
const [result] = await client.Change_JobAsync({
Business_Process_Parameters: {
Auto_Complete: true,
Run_Now: true,
},
Change_Job_Data: {
Worker_Reference: wdRef('Employee_ID', data.workerId),
Effective_Date: data.effectiveDate,
Change_Job_Detail_Data: changeJobDetailData,
},
})
const eventRef = result?.Event_Reference
return NextResponse.json({
success: true,
output: {
eventId: extractRefId(eventRef),
workerId: data.workerId,
effectiveDate: data.effectiveDate,
},
})
} catch (error) {
logger.error(`[${requestId}] Workday change job failed`, { error })
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,134 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { createWorkdaySoapClient, extractRefId, wdRef } from '@/tools/workday/soap'
export const dynamic = 'force-dynamic'
const logger = createLogger('WorkdayCreatePrehireAPI')
const RequestSchema = z.object({
tenantUrl: z.string().min(1),
tenant: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
legalName: z.string().min(1),
email: z.string().optional(),
phoneNumber: z.string().optional(),
address: z.string().optional(),
countryCode: z.string().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const data = RequestSchema.parse(body)
if (!data.email && !data.phoneNumber && !data.address) {
return NextResponse.json(
{
success: false,
error: 'At least one contact method (email, phone, or address) is required',
},
{ status: 400 }
)
}
const parts = data.legalName.trim().split(/\s+/)
const firstName = parts[0] ?? ''
const lastName = parts.length > 1 ? parts.slice(1).join(' ') : ''
if (!lastName) {
return NextResponse.json(
{ success: false, error: 'Legal name must include both a first name and last name' },
{ status: 400 }
)
}
const client = await createWorkdaySoapClient(
data.tenantUrl,
data.tenant,
'staffing',
data.username,
data.password
)
const contactData: Record<string, unknown> = {}
if (data.email) {
contactData.Email_Address_Data = [
{
Email_Address: data.email,
Usage_Data: {
Type_Data: { Type_Reference: wdRef('Communication_Usage_Type_ID', 'WORK') },
Public: true,
},
},
]
}
if (data.phoneNumber) {
contactData.Phone_Data = [
{
Phone_Number: data.phoneNumber,
Phone_Device_Type_Reference: wdRef('Phone_Device_Type_ID', 'Landline'),
Usage_Data: {
Type_Data: { Type_Reference: wdRef('Communication_Usage_Type_ID', 'WORK') },
Public: true,
},
},
]
}
if (data.address) {
contactData.Address_Data = [
{
Formatted_Address: data.address,
Usage_Data: {
Type_Data: { Type_Reference: wdRef('Communication_Usage_Type_ID', 'WORK') },
Public: true,
},
},
]
}
const [result] = await client.Put_ApplicantAsync({
Applicant_Data: {
Personal_Data: {
Name_Data: {
Legal_Name_Data: {
Name_Detail_Data: {
Country_Reference: wdRef('ISO_3166-1_Alpha-2_Code', data.countryCode ?? 'US'),
First_Name: firstName,
Last_Name: lastName,
},
},
},
Contact_Information_Data: contactData,
},
},
})
const applicantRef = result?.Applicant_Reference
return NextResponse.json({
success: true,
output: {
preHireId: extractRefId(applicantRef),
descriptor: applicantRef?.attributes?.Descriptor ?? null,
},
})
} catch (error) {
logger.error(`[${requestId}] Workday create prehire failed`, { error })
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,101 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import {
createWorkdaySoapClient,
extractRefId,
normalizeSoapArray,
type WorkdayCompensationDataSoap,
type WorkdayCompensationPlanSoap,
type WorkdayWorkerSoap,
} from '@/tools/workday/soap'
export const dynamic = 'force-dynamic'
const logger = createLogger('WorkdayGetCompensationAPI')
const RequestSchema = z.object({
tenantUrl: z.string().min(1),
tenant: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
workerId: z.string().min(1),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const data = RequestSchema.parse(body)
const client = await createWorkdaySoapClient(
data.tenantUrl,
data.tenant,
'humanResources',
data.username,
data.password
)
const [result] = await client.Get_WorkersAsync({
Request_References: {
Worker_Reference: {
ID: { attributes: { 'wd:type': 'Employee_ID' }, $value: data.workerId },
},
},
Response_Group: {
Include_Reference: true,
Include_Compensation: true,
},
})
const worker =
normalizeSoapArray(
result?.Response_Data?.Worker as WorkdayWorkerSoap | WorkdayWorkerSoap[] | undefined
)[0] ?? null
const compensationData = worker?.Worker_Data?.Compensation_Data
const mapPlan = (p: WorkdayCompensationPlanSoap) => ({
id: extractRefId(p.Compensation_Plan_Reference) ?? null,
planName: p.Compensation_Plan_Reference?.attributes?.Descriptor ?? null,
amount: p.Amount ?? p.Per_Unit_Amount ?? p.Individual_Target_Amount ?? null,
currency: extractRefId(p.Currency_Reference) ?? null,
frequency: extractRefId(p.Frequency_Reference) ?? null,
})
const planTypeKeys: (keyof WorkdayCompensationDataSoap)[] = [
'Employee_Base_Pay_Plan_Assignment_Data',
'Employee_Salary_Unit_Plan_Assignment_Data',
'Employee_Bonus_Plan_Assignment_Data',
'Employee_Allowance_Plan_Assignment_Data',
'Employee_Commission_Plan_Assignment_Data',
'Employee_Stock_Plan_Assignment_Data',
'Employee_Period_Salary_Plan_Assignment_Data',
]
const compensationPlans: ReturnType<typeof mapPlan>[] = []
for (const key of planTypeKeys) {
for (const plan of normalizeSoapArray(compensationData?.[key])) {
compensationPlans.push(mapPlan(plan))
}
}
return NextResponse.json({
success: true,
output: { compensationPlans },
})
} catch (error) {
logger.error(`[${requestId}] Workday get compensation failed`, { error })
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,94 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import {
createWorkdaySoapClient,
extractRefId,
normalizeSoapArray,
type WorkdayOrganizationSoap,
} from '@/tools/workday/soap'
export const dynamic = 'force-dynamic'
const logger = createLogger('WorkdayGetOrganizationsAPI')
const RequestSchema = z.object({
tenantUrl: z.string().min(1),
tenant: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
type: z.string().optional(),
limit: z.number().optional(),
offset: z.number().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const data = RequestSchema.parse(body)
const client = await createWorkdaySoapClient(
data.tenantUrl,
data.tenant,
'humanResources',
data.username,
data.password
)
const limit = data.limit ?? 20
const offset = data.offset ?? 0
const page = offset > 0 ? Math.floor(offset / limit) + 1 : 1
const [result] = await client.Get_OrganizationsAsync({
Response_Filter: { Page: page, Count: limit },
Request_Criteria: data.type
? {
Organization_Type_Reference: {
ID: {
attributes: { 'wd:type': 'Organization_Type_ID' },
$value: data.type,
},
},
}
: undefined,
Response_Group: { Include_Hierarchy_Data: true },
})
const orgsArray = normalizeSoapArray(
result?.Response_Data?.Organization as
| WorkdayOrganizationSoap
| WorkdayOrganizationSoap[]
| undefined
)
const organizations = orgsArray.map((o) => ({
id: extractRefId(o.Organization_Reference) ?? null,
descriptor: o.Organization_Descriptor ?? null,
type: extractRefId(o.Organization_Data?.Organization_Type_Reference) ?? null,
subtype: extractRefId(o.Organization_Data?.Organization_Subtype_Reference) ?? null,
isActive: o.Organization_Data?.Inactive != null ? !o.Organization_Data.Inactive : null,
}))
const total = result?.Response_Results?.Total_Results ?? organizations.length
return NextResponse.json({
success: true,
output: { organizations, total },
})
} catch (error) {
logger.error(`[${requestId}] Workday get organizations failed`, { error })
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,87 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import {
createWorkdaySoapClient,
extractRefId,
normalizeSoapArray,
type WorkdayWorkerSoap,
} from '@/tools/workday/soap'
export const dynamic = 'force-dynamic'
const logger = createLogger('WorkdayGetWorkerAPI')
const RequestSchema = z.object({
tenantUrl: z.string().min(1),
tenant: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
workerId: z.string().min(1),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const data = RequestSchema.parse(body)
const client = await createWorkdaySoapClient(
data.tenantUrl,
data.tenant,
'humanResources',
data.username,
data.password
)
const [result] = await client.Get_WorkersAsync({
Request_References: {
Worker_Reference: {
ID: { attributes: { 'wd:type': 'Employee_ID' }, $value: data.workerId },
},
},
Response_Group: {
Include_Reference: true,
Include_Personal_Information: true,
Include_Employment_Information: true,
Include_Compensation: true,
Include_Organizations: true,
},
})
const worker =
normalizeSoapArray(
result?.Response_Data?.Worker as WorkdayWorkerSoap | WorkdayWorkerSoap[] | undefined
)[0] ?? null
return NextResponse.json({
success: true,
output: {
worker: worker
? {
id: extractRefId(worker.Worker_Reference) ?? null,
descriptor: worker.Worker_Descriptor ?? null,
personalData: worker.Worker_Data?.Personal_Data ?? null,
employmentData: worker.Worker_Data?.Employment_Data ?? null,
compensationData: worker.Worker_Data?.Compensation_Data ?? null,
organizationData: worker.Worker_Data?.Organization_Data ?? null,
}
: null,
},
})
} catch (error) {
logger.error(`[${requestId}] Workday get worker failed`, { error })
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,78 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { createWorkdaySoapClient, extractRefId, wdRef } from '@/tools/workday/soap'
export const dynamic = 'force-dynamic'
const logger = createLogger('WorkdayHireAPI')
const RequestSchema = z.object({
tenantUrl: z.string().min(1),
tenant: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
preHireId: z.string().min(1),
positionId: z.string().min(1),
hireDate: z.string().min(1),
employeeType: z.string().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const data = RequestSchema.parse(body)
const client = await createWorkdaySoapClient(
data.tenantUrl,
data.tenant,
'staffing',
data.username,
data.password
)
const [result] = await client.Hire_EmployeeAsync({
Business_Process_Parameters: {
Auto_Complete: true,
Run_Now: true,
},
Hire_Employee_Data: {
Applicant_Reference: wdRef('Applicant_ID', data.preHireId),
Position_Reference: wdRef('Position_ID', data.positionId),
Hire_Date: data.hireDate,
Hire_Employee_Event_Data: {
Employee_Type_Reference: wdRef('Employee_Type_ID', data.employeeType ?? 'Regular'),
First_Day_of_Work: data.hireDate,
},
},
})
const employeeRef = result?.Employee_Reference
const eventRef = result?.Event_Reference
return NextResponse.json({
success: true,
output: {
workerId: extractRefId(employeeRef),
employeeId: extractRefId(employeeRef),
eventId: extractRefId(eventRef),
hireDate: data.hireDate,
},
})
} catch (error) {
logger.error(`[${requestId}] Workday hire employee failed`, { error })
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,83 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import {
createWorkdaySoapClient,
extractRefId,
normalizeSoapArray,
type WorkdayWorkerSoap,
} from '@/tools/workday/soap'
export const dynamic = 'force-dynamic'
const logger = createLogger('WorkdayListWorkersAPI')
const RequestSchema = z.object({
tenantUrl: z.string().min(1),
tenant: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
limit: z.number().optional(),
offset: z.number().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const data = RequestSchema.parse(body)
const client = await createWorkdaySoapClient(
data.tenantUrl,
data.tenant,
'humanResources',
data.username,
data.password
)
const limit = data.limit ?? 20
const offset = data.offset ?? 0
const page = offset > 0 ? Math.floor(offset / limit) + 1 : 1
const [result] = await client.Get_WorkersAsync({
Response_Filter: { Page: page, Count: limit },
Response_Group: {
Include_Reference: true,
Include_Personal_Information: true,
Include_Employment_Information: true,
},
})
const workersArray = normalizeSoapArray(
result?.Response_Data?.Worker as WorkdayWorkerSoap | WorkdayWorkerSoap[] | undefined
)
const workers = workersArray.map((w) => ({
id: extractRefId(w.Worker_Reference) ?? null,
descriptor: w.Worker_Descriptor ?? null,
personalData: w.Worker_Data?.Personal_Data ?? null,
employmentData: w.Worker_Data?.Employment_Data ?? null,
}))
const total = result?.Response_Results?.Total_Results ?? workers.length
return NextResponse.json({
success: true,
output: { workers, total },
})
} catch (error) {
logger.error(`[${requestId}] Workday list workers failed`, { error })
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,77 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { createWorkdaySoapClient, extractRefId, wdRef } from '@/tools/workday/soap'
export const dynamic = 'force-dynamic'
const logger = createLogger('WorkdayTerminateAPI')
const RequestSchema = z.object({
tenantUrl: z.string().min(1),
tenant: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
workerId: z.string().min(1),
terminationDate: z.string().min(1),
reason: z.string().min(1),
notificationDate: z.string().optional(),
lastDayOfWork: z.string().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const data = RequestSchema.parse(body)
const client = await createWorkdaySoapClient(
data.tenantUrl,
data.tenant,
'staffing',
data.username,
data.password
)
const [result] = await client.Terminate_EmployeeAsync({
Business_Process_Parameters: {
Auto_Complete: true,
Run_Now: true,
},
Terminate_Employee_Data: {
Employee_Reference: wdRef('Employee_ID', data.workerId),
Termination_Date: data.terminationDate,
Terminate_Event_Data: {
Primary_Reason_Reference: wdRef('Termination_Subcategory_ID', data.reason),
Last_Day_of_Work: data.lastDayOfWork ?? data.terminationDate,
Notification_Date: data.notificationDate ?? data.terminationDate,
},
},
})
const eventRef = result?.Event_Reference
return NextResponse.json({
success: true,
output: {
eventId: extractRefId(eventRef),
workerId: data.workerId,
terminationDate: data.terminationDate,
},
})
} catch (error) {
logger.error(`[${requestId}] Workday terminate employee failed`, { error })
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,66 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { createWorkdaySoapClient, extractRefId, wdRef } from '@/tools/workday/soap'
export const dynamic = 'force-dynamic'
const logger = createLogger('WorkdayUpdateWorkerAPI')
const RequestSchema = z.object({
tenantUrl: z.string().min(1),
tenant: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
workerId: z.string().min(1),
fields: z.record(z.unknown()),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const data = RequestSchema.parse(body)
const client = await createWorkdaySoapClient(
data.tenantUrl,
data.tenant,
'humanResources',
data.username,
data.password
)
const [result] = await client.Change_Personal_InformationAsync({
Business_Process_Parameters: {
Auto_Complete: true,
Run_Now: true,
},
Change_Personal_Information_Data: {
Person_Reference: wdRef('Employee_ID', data.workerId),
Personal_Information_Data: data.fields,
},
})
return NextResponse.json({
success: true,
output: {
eventId: extractRefId(result?.Personal_Information_Change_Event_Reference),
workerId: data.workerId,
},
})
} catch (error) {
logger.error(`[${requestId}] Workday update worker failed`, { error })
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}

View File

@@ -93,7 +93,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
const fileName = rawFile.name || 'untitled'
const fileName = rawFile.name || 'untitled.md'
const maxSize = 100 * 1024 * 1024
if (rawFile.size > maxSize) {

View File

@@ -71,11 +71,6 @@ export const ChatInput: React.FC<{
}
}
// Adjust height on input change
useEffect(() => {
adjustTextareaHeight()
}, [inputValue])
// Close the input when clicking outside (only when empty)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -94,17 +89,14 @@ export const ChatInput: React.FC<{
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [inputValue])
// Handle focus and initial height when activated
useEffect(() => {
if (isActive && textareaRef.current) {
textareaRef.current.focus()
adjustTextareaHeight() // Adjust height when becoming active
}
}, [isActive])
const handleActivate = () => {
setIsActive(true)
// Focus is now handled by the useEffect above
requestAnimationFrame(() => {
if (textareaRef.current) {
textareaRef.current.focus()
adjustTextareaHeight()
}
})
}
// Handle file selection
@@ -186,6 +178,7 @@ export const ChatInput: React.FC<{
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInputValue(e.target.value)
adjustTextareaHeight()
}
// Handle voice start with smooth transition to voice-first mode

View File

@@ -78,9 +78,10 @@ export function VoiceInterface({
const currentStateRef = useRef<'idle' | 'listening' | 'agent_speaking'>('idle')
const isCallEndedRef = useRef(false)
useEffect(() => {
currentStateRef.current = state
}, [state])
const updateState = useCallback((next: 'idle' | 'listening' | 'agent_speaking') => {
setState(next)
currentStateRef.current = next
}, [])
const recognitionRef = useRef<SpeechRecognition | null>(null)
const mediaStreamRef = useRef<MediaStream | null>(null)
@@ -97,9 +98,10 @@ export function VoiceInterface({
(window as WindowWithSpeech).webkitSpeechRecognition
)
useEffect(() => {
isMutedRef.current = isMuted
}, [isMuted])
const updateIsMuted = useCallback((next: boolean) => {
setIsMuted(next)
isMutedRef.current = next
}, [])
const setResponseTimeout = useCallback(() => {
if (responseTimeoutRef.current) {
@@ -108,7 +110,7 @@ export function VoiceInterface({
responseTimeoutRef.current = setTimeout(() => {
if (currentStateRef.current === 'listening') {
setState('idle')
updateState('idle')
}
}, 5000)
}, [])
@@ -123,10 +125,10 @@ export function VoiceInterface({
useEffect(() => {
if (isPlayingAudio && state !== 'agent_speaking') {
clearResponseTimeout()
setState('agent_speaking')
updateState('agent_speaking')
setCurrentTranscript('')
setIsMuted(true)
updateIsMuted(true)
if (mediaStreamRef.current) {
mediaStreamRef.current.getAudioTracks().forEach((track) => {
track.enabled = false
@@ -141,17 +143,17 @@ export function VoiceInterface({
}
}
} else if (!isPlayingAudio && state === 'agent_speaking') {
setState('idle')
updateState('idle')
setCurrentTranscript('')
setIsMuted(false)
updateIsMuted(false)
if (mediaStreamRef.current) {
mediaStreamRef.current.getAudioTracks().forEach((track) => {
track.enabled = true
})
}
}
}, [isPlayingAudio, state, clearResponseTimeout])
}, [isPlayingAudio, state, clearResponseTimeout, updateState, updateIsMuted])
const setupAudio = useCallback(async () => {
try {
@@ -310,7 +312,7 @@ export function VoiceInterface({
return
}
setState('listening')
updateState('listening')
setCurrentTranscript('')
if (recognitionRef.current) {
@@ -320,10 +322,10 @@ export function VoiceInterface({
logger.error('Error starting recognition:', error)
}
}
}, [isInitialized, isMuted, state])
}, [isInitialized, isMuted, state, updateState])
const stopListening = useCallback(() => {
setState('idle')
updateState('idle')
setCurrentTranscript('')
if (recognitionRef.current) {
@@ -333,15 +335,15 @@ export function VoiceInterface({
// Ignore
}
}
}, [])
}, [updateState])
const handleInterrupt = useCallback(() => {
if (state === 'agent_speaking') {
onInterrupt?.()
setState('listening')
updateState('listening')
setCurrentTranscript('')
setIsMuted(false)
updateIsMuted(false)
if (mediaStreamRef.current) {
mediaStreamRef.current.getAudioTracks().forEach((track) => {
track.enabled = true
@@ -356,14 +358,14 @@ export function VoiceInterface({
}
}
}
}, [state, onInterrupt])
}, [state, onInterrupt, updateState, updateIsMuted])
const handleCallEnd = useCallback(() => {
isCallEndedRef.current = true
setState('idle')
updateState('idle')
setCurrentTranscript('')
setIsMuted(false)
updateIsMuted(false)
if (recognitionRef.current) {
try {
@@ -376,7 +378,7 @@ export function VoiceInterface({
clearResponseTimeout()
onInterrupt?.()
onCallEnd?.()
}, [onCallEnd, onInterrupt, clearResponseTimeout])
}, [onCallEnd, onInterrupt, clearResponseTimeout, updateState, updateIsMuted])
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -397,7 +399,7 @@ export function VoiceInterface({
}
const newMutedState = !isMuted
setIsMuted(newMutedState)
updateIsMuted(newMutedState)
if (mediaStreamRef.current) {
mediaStreamRef.current.getAudioTracks().forEach((track) => {
@@ -410,7 +412,7 @@ export function VoiceInterface({
} else if (state === 'idle') {
startListening()
}
}, [isMuted, state, handleInterrupt, stopListening, startListening])
}, [isMuted, state, handleInterrupt, stopListening, startListening, updateIsMuted])
useEffect(() => {
if (isSupported) {

View File

@@ -1,5 +1,6 @@
export { ErrorState, type ErrorStateProps } from './error'
export { InlineRenameInput } from './inline-rename-input'
export { MessageActions } from './message-actions'
export { ownerCell } from './resource/components/owner-cell/owner-cell'
export type {
BreadcrumbEditing,

View File

@@ -0,0 +1 @@
export { MessageActions } from './message-actions'

View File

@@ -0,0 +1,84 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Check, Copy, Ellipsis, Hash } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/emcn'
interface MessageActionsProps {
content: string
requestId?: string
}
export function MessageActions({ content, requestId }: MessageActionsProps) {
const [copied, setCopied] = useState<'message' | 'request' | null>(null)
const resetTimeoutRef = useRef<number | null>(null)
useEffect(() => {
return () => {
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current)
}
}
}, [])
const copyToClipboard = useCallback(async (text: string, type: 'message' | 'request') => {
try {
await navigator.clipboard.writeText(text)
setCopied(type)
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current)
}
resetTimeoutRef.current = window.setTimeout(() => setCopied(null), 1500)
} catch {
return
}
}, [])
if (!content && !requestId) {
return null
}
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type='button'
aria-label='More options'
className='flex h-5 w-5 items-center justify-center rounded-sm text-[var(--text-icon)] opacity-0 transition-colors transition-opacity hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)] focus-visible:opacity-100 focus-visible:outline-none group-hover/msg:opacity-100 data-[state=open]:opacity-100'
onClick={(event) => event.stopPropagation()}
>
<Ellipsis className='h-3 w-3' strokeWidth={2} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' side='top' sideOffset={4}>
<DropdownMenuItem
disabled={!content}
onSelect={(event) => {
event.stopPropagation()
void copyToClipboard(content, 'message')
}}
>
{copied === 'message' ? <Check /> : <Copy />}
<span>Copy Message</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={!requestId}
onSelect={(event) => {
event.stopPropagation()
if (requestId) {
void copyToClipboard(requestId, 'request')
}
}}
>
{copied === 'request' ? <Check /> : <Hash />}
<span>Copy Request ID</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -215,16 +215,13 @@ function TextEditor({
onSaveStatusChange?.(saveStatus)
}, [saveStatus, onSaveStatusChange])
useEffect(() => {
if (saveRef) {
saveRef.current = saveImmediately
}
return () => {
if (saveRef) {
saveRef.current = null
}
}
}, [saveRef, saveImmediately])
if (saveRef) saveRef.current = saveImmediately
useEffect(
() => () => {
if (saveRef) saveRef.current = null
},
[saveRef]
)
useEffect(() => {
if (!isResizing) return

View File

@@ -151,6 +151,8 @@ export function Files() {
}
const justCreatedFileIdRef = useRef<string | null>(null)
const filesRef = useRef(files)
filesRef.current = files
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 })
@@ -483,11 +485,11 @@ export function Files() {
if (isJustCreated) {
setPreviewMode('editor')
} else {
const file = selectedFileId ? files.find((f) => f.id === selectedFileId) : null
const file = selectedFileId ? filesRef.current.find((f) => f.id === selectedFileId) : null
const canPreview = file ? isPreviewable(file) : false
setPreviewMode(canPreview ? 'preview' : 'editor')
}
}, [selectedFileId, files])
}, [selectedFileId])
useEffect(() => {
if (!selectedFile) return

View File

@@ -160,8 +160,8 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor
])
const handleOpenWorkflow = useCallback(() => {
router.push(`/workspace/${workspaceId}/w/${workflowId}`)
}, [router, workspaceId, workflowId])
window.open(`/workspace/${workspaceId}/w/${workflowId}`, '_blank')
}, [workspaceId, workflowId])
return (
<>
@@ -260,7 +260,7 @@ function EmbeddedFileActions({ workspaceId, fileId }: EmbeddedFileActionsProps)
}, [file])
const handleOpenInFiles = useCallback(() => {
router.push(`/workspace/${workspaceId}/files?fileId=${fileId}`)
router.push(`/workspace/${workspaceId}/files?fileId=${encodeURIComponent(fileId)}`)
}, [router, workspaceId, fileId])
return (
@@ -344,10 +344,10 @@ interface EmbeddedFileProps {
}
function EmbeddedFile({ workspaceId, fileId, previewMode }: EmbeddedFileProps) {
const { data: files = [], isLoading } = useWorkspaceFiles(workspaceId)
const { data: files = [], isLoading, isFetching } = useWorkspaceFiles(workspaceId)
const file = useMemo(() => files.find((f) => f.id === fileId), [files, fileId])
if (isLoading) return LOADING_SKELETON
if (isLoading || (isFetching && !file)) return LOADING_SKELETON
if (!file) {
return (

View File

@@ -1,6 +1,6 @@
'use client'
import { memo, useCallback, useEffect, useState } from 'react'
import { forwardRef, memo, useCallback, useState } from 'react'
import { cn } from '@/lib/core/utils/cn'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
@@ -31,68 +31,79 @@ interface MothershipViewProps {
className?: string
}
export const MothershipView = memo(function MothershipView({
workspaceId,
chatId,
resources,
activeResourceId,
onSelectResource,
onAddResource,
onRemoveResource,
onReorderResources,
onCollapse,
isCollapsed,
className,
}: MothershipViewProps) {
const active = resources.find((r) => r.id === activeResourceId) ?? resources[0] ?? null
export const MothershipView = memo(
forwardRef<HTMLDivElement, MothershipViewProps>(function MothershipView(
{
workspaceId,
chatId,
resources,
activeResourceId,
onSelectResource,
onAddResource,
onRemoveResource,
onReorderResources,
onCollapse,
isCollapsed,
className,
}: MothershipViewProps,
ref
) {
const active = resources.find((r) => r.id === activeResourceId) ?? resources[0] ?? null
const [previewMode, setPreviewMode] = useState<PreviewMode>('preview')
const handleCyclePreview = useCallback(() => setPreviewMode((m) => PREVIEW_CYCLE[m]), [])
const [previewMode, setPreviewMode] = useState<PreviewMode>('preview')
const [prevActiveId, setPrevActiveId] = useState<string | null | undefined>(active?.id)
const handleCyclePreview = useCallback(() => setPreviewMode((m) => PREVIEW_CYCLE[m]), [])
useEffect(() => {
setPreviewMode('preview')
}, [active?.id])
// Reset preview mode to default when the active resource changes (guarded render-phase update)
if (active?.id !== prevActiveId) {
setPrevActiveId(active?.id)
setPreviewMode('preview')
}
const isActivePreviewable =
active?.type === 'file' && RICH_PREVIEWABLE_EXTENSIONS.has(getFileExtension(active.title))
const isActivePreviewable =
active?.type === 'file' && RICH_PREVIEWABLE_EXTENSIONS.has(getFileExtension(active.title))
return (
<div
className={cn(
'relative z-10 flex h-full flex-col overflow-hidden border-[var(--border)] bg-[var(--bg)] transition-[width,min-width,border-width] duration-300 ease-out',
isCollapsed ? 'w-0 min-w-0 border-l-0' : 'w-[60%] border-l',
className
)}
>
<div className='flex min-h-0 flex-1 flex-col'>
<ResourceTabs
workspaceId={workspaceId}
chatId={chatId}
resources={resources}
activeId={active?.id ?? null}
onSelect={onSelectResource}
onAddResource={onAddResource}
onRemoveResource={onRemoveResource}
onReorderResources={onReorderResources}
onCollapse={onCollapse}
actions={active ? <ResourceActions workspaceId={workspaceId} resource={active} /> : null}
previewMode={isActivePreviewable ? previewMode : undefined}
onCyclePreviewMode={isActivePreviewable ? handleCyclePreview : undefined}
/>
<div className='min-h-0 flex-1 overflow-hidden'>
{active ? (
<ResourceContent
workspaceId={workspaceId}
resource={active}
previewMode={isActivePreviewable ? previewMode : undefined}
/>
) : (
<div className='flex h-full items-center justify-center text-[14px] text-[var(--text-muted)]'>
Click "+" above to add a resource
</div>
)}
return (
<div
ref={ref}
className={cn(
'relative z-10 flex h-full flex-col overflow-hidden border-[var(--border)] bg-[var(--bg)] transition-[width,min-width,border-width] duration-300 ease-out',
isCollapsed ? 'w-0 min-w-0 border-l-0' : 'w-[60%] border-l',
className
)}
>
<div className='flex min-h-0 flex-1 flex-col'>
<ResourceTabs
workspaceId={workspaceId}
chatId={chatId}
resources={resources}
activeId={active?.id ?? null}
onSelect={onSelectResource}
onAddResource={onAddResource}
onRemoveResource={onRemoveResource}
onReorderResources={onReorderResources}
onCollapse={onCollapse}
actions={
active ? <ResourceActions workspaceId={workspaceId} resource={active} /> : null
}
previewMode={isActivePreviewable ? previewMode : undefined}
onCyclePreviewMode={isActivePreviewable ? handleCyclePreview : undefined}
/>
<div className='min-h-0 flex-1 overflow-hidden'>
{active ? (
<ResourceContent
workspaceId={workspaceId}
resource={active}
previewMode={isActivePreviewable ? previewMode : undefined}
/>
) : (
<div className='flex h-full items-center justify-center text-[14px] text-[var(--text-muted)]'>
Click "+" above to add a resource
</div>
)}
</div>
</div>
</div>
</div>
)
})
)
})
)

View File

@@ -105,6 +105,8 @@ export interface TemplatePrompt {
title: string
prompt: string
image?: string
// Base block type keys from `blocks/registry.ts` for integrations used by this template.
integrationBlockTypes: string[]
modules: ModuleTag[]
category: Category
tags: Tag[]
@@ -126,6 +128,7 @@ export const TEMPLATES: TemplatePrompt[] = [
prompt:
'Create a self-healing CRM table that keeps track of all my customers by integrating with my existing data sources. Schedule a recurring job every morning to automatically pull updates from all relevant data sources and keep my CRM up to date.',
image: '/templates/crm-light.png',
integrationBlockTypes: [],
modules: ['tables', 'scheduled', 'workflows'],
category: 'popular',
tags: ['founder', 'sales', 'crm', 'sync', 'automation'],
@@ -137,6 +140,7 @@ export const TEMPLATES: TemplatePrompt[] = [
prompt:
'Create an agent that checks my Google Calendar each morning, researches every attendee and topic on the web, and prepares a brief for each meeting so I walk in fully prepared. Schedule it to run every weekday morning.',
image: '/templates/meeting-prep-dark.png',
integrationBlockTypes: ['google_calendar'],
modules: ['agent', 'scheduled', 'workflows'],
category: 'popular',
tags: ['founder', 'sales', 'research', 'automation'],
@@ -148,6 +152,7 @@ export const TEMPLATES: TemplatePrompt[] = [
prompt:
'Create a file of all my todos then go one by one and check off every time a todo is done. Look at my calendar and see what I have to do.',
image: '/templates/todo-list-light.png',
integrationBlockTypes: [],
modules: ['files', 'agent', 'workflows'],
category: 'popular',
tags: ['individual', 'automation'],
@@ -159,6 +164,7 @@ export const TEMPLATES: TemplatePrompt[] = [
prompt:
'Build an agent that takes a topic, searches the web for the latest information, summarizes key findings, and compiles them into a clean document I can review.',
image: '/templates/research-assistant-dark.png',
integrationBlockTypes: [],
modules: ['agent', 'files', 'workflows'],
category: 'popular',
tags: ['founder', 'research', 'content', 'individual'],
@@ -170,6 +176,7 @@ export const TEMPLATES: TemplatePrompt[] = [
prompt:
'Create a workflow that reads my Gmail inbox, identifies emails that need a response, and drafts contextual replies for each one. Schedule it to run every hour.',
image: '/templates/gmail-agent-dark.png',
integrationBlockTypes: ['gmail'],
modules: ['agent', 'workflows'],
category: 'popular',
tags: ['individual', 'communication', 'automation'],
@@ -181,6 +188,7 @@ export const TEMPLATES: TemplatePrompt[] = [
prompt:
'Create a table that tracks all my expenses by pulling transactions from my connected accounts. Categorize each expense automatically and generate a weekly summary report.',
image: '/templates/expense-tracker-light.png',
integrationBlockTypes: [],
modules: ['tables', 'scheduled', 'workflows'],
category: 'popular',
tags: ['finance', 'individual', 'reporting'],
@@ -193,6 +201,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'RFP and proposal drafter',
prompt:
'Create a knowledge base from my past proposals, case studies, and company information. Then build an agent that drafts responses to new RFPs by matching requirements to relevant past work, generating tailored sections, and compiling a complete proposal file.',
integrationBlockTypes: [],
modules: ['knowledge-base', 'files', 'agent'],
category: 'sales',
tags: ['sales', 'content', 'enterprise'],
@@ -202,6 +211,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Competitive battle cards',
prompt:
'Create an agent that deep-researches each of my competitors using web search — their product features, pricing, positioning, strengths, and weaknesses — and generates a structured battle card document for each one that my sales team can reference during calls.',
integrationBlockTypes: [],
modules: ['agent', 'files', 'workflows'],
category: 'sales',
tags: ['sales', 'research', 'content'],
@@ -211,6 +221,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'QBR prep agent',
prompt:
'Build a workflow that compiles everything needed for a quarterly business review — pulling customer usage data, support ticket history, billing summary, and key milestones from my tables — and generates a polished QBR document ready to present.',
integrationBlockTypes: [],
modules: ['tables', 'files', 'agent', 'workflows'],
category: 'sales',
tags: ['sales', 'support', 'reporting'],
@@ -220,6 +231,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'CRM knowledge search',
prompt:
'Create a knowledge base connected to my Salesforce account so all deals, contacts, notes, and activities are automatically synced and searchable. Then build an agent I can ask things like "what\'s the history with Acme Corp?" or "who was involved in the last enterprise deal?" and get instant answers with CRM record citations.',
integrationBlockTypes: ['salesforce'],
modules: ['knowledge-base', 'agent'],
category: 'sales',
tags: ['sales', 'crm', 'research'],
@@ -229,6 +241,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'HubSpot deal search',
prompt:
'Create a knowledge base connected to my HubSpot account so all deals, contacts, and activity history are automatically synced and searchable. Then build an agent I can ask things like "what happened with the Stripe integration deal?" or "which deals closed last quarter over $50k?" and get answers with HubSpot record links.',
integrationBlockTypes: ['hubspot'],
modules: ['knowledge-base', 'agent'],
category: 'sales',
tags: ['sales', 'crm', 'research'],
@@ -238,6 +251,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Lead enrichment pipeline',
prompt:
'Build a workflow that watches my leads table for new entries, enriches each lead with company size, funding, tech stack, and decision-maker contacts using Apollo and web search, then updates the table with the enriched information.',
integrationBlockTypes: ['apollo'],
modules: ['tables', 'agent', 'workflows'],
category: 'sales',
tags: ['sales', 'crm', 'automation', 'research'],
@@ -247,6 +261,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Prospect researcher',
prompt:
'Create an agent that takes a company name, deep-researches them across the web, finds key decision-makers, recent news, funding rounds, and pain points, then compiles a prospect brief I can review before outreach.',
integrationBlockTypes: [],
modules: ['agent', 'files', 'workflows'],
category: 'sales',
tags: ['sales', 'research'],
@@ -256,6 +271,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Outbound sequence builder',
prompt:
'Build a workflow that reads leads from my table, researches each prospect and their company on the web, writes a personalized cold email tailored to their role and pain points, and sends it via Gmail. Schedule it to run daily to process new leads automatically.',
integrationBlockTypes: ['gmail'],
modules: ['tables', 'agent', 'workflows'],
category: 'sales',
tags: ['sales', 'communication', 'automation'],
@@ -265,6 +281,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Deal pipeline tracker',
prompt:
'Create a table with columns for deal name, stage, amount, close date, and next steps. Build a workflow that syncs open deals from Salesforce into this table daily, and sends me a Slack summary each morning of deals that need attention or are at risk of slipping.',
integrationBlockTypes: ['salesforce', 'slack'],
modules: ['tables', 'scheduled', 'agent', 'workflows'],
category: 'sales',
tags: ['sales', 'crm', 'monitoring', 'reporting'],
@@ -274,6 +291,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Win/loss analyzer',
prompt:
'Build a workflow that pulls closed deals from HubSpot each week, analyzes patterns in wins vs losses — deal size, industry, sales cycle length, objections — and generates a report file with actionable insights on what to change. Schedule it to run every Monday.',
integrationBlockTypes: ['hubspot'],
modules: ['agent', 'files', 'scheduled', 'workflows'],
category: 'sales',
tags: ['sales', 'crm', 'analysis', 'reporting'],
@@ -283,6 +301,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Sales call analyzer',
prompt:
'Build a workflow that pulls call transcripts from Gong after each sales call, identifies key objections raised, action items promised, and competitor mentions, updates the deal record in my CRM, and posts a call summary with next steps to the Slack deal channel.',
integrationBlockTypes: ['gong', 'slack'],
modules: ['agent', 'tables', 'workflows'],
category: 'sales',
tags: ['sales', 'analysis', 'communication'],
@@ -292,6 +311,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Webflow lead capture pipeline',
prompt:
'Create a workflow that monitors new Webflow form submissions, enriches each lead with company and contact data using Apollo and web search, adds them to a tracking table with a lead score, and sends a Slack notification to the sales team for high-potential leads.',
integrationBlockTypes: ['webflow', 'apollo', 'slack'],
modules: ['tables', 'agent', 'workflows'],
category: 'sales',
tags: ['sales', 'crm', 'automation'],
@@ -303,6 +323,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Customer support bot',
prompt:
'Create a knowledge base and connect it to my Notion or Google Docs so it stays synced with my product documentation automatically. Then build an agent that answers customer questions using it with sourced citations and deploy it as a chat endpoint.',
integrationBlockTypes: ['notion', 'google_docs'],
modules: ['knowledge-base', 'agent', 'workflows'],
category: 'support',
tags: ['support', 'communication', 'automation'],
@@ -312,6 +333,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Slack Q&A bot',
prompt:
'Create a knowledge base connected to my Notion workspace so it stays synced with my company wiki. Then build a workflow that monitors Slack channels for questions and answers them using the knowledge base with source citations.',
integrationBlockTypes: ['notion', 'slack'],
modules: ['knowledge-base', 'agent', 'workflows'],
category: 'support',
tags: ['support', 'communication', 'team'],
@@ -321,6 +343,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Customer feedback analyzer',
prompt:
'Build a scheduled workflow that pulls support tickets and conversations from Intercom daily, categorizes them by theme and sentiment, tracks trends in a table, and sends a weekly Slack report highlighting the top feature requests and pain points.',
integrationBlockTypes: ['intercom', 'slack'],
modules: ['tables', 'scheduled', 'agent', 'workflows'],
category: 'support',
tags: ['support', 'product', 'analysis', 'reporting'],
@@ -330,6 +353,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Churn risk detector',
prompt:
'Create a workflow that monitors customer activity — support ticket frequency, response sentiment, usage patterns — scores each account for churn risk in a table, and triggers a Slack alert to the account team when a customer crosses the risk threshold.',
integrationBlockTypes: ['slack'],
modules: ['tables', 'scheduled', 'agent', 'workflows'],
category: 'support',
tags: ['support', 'sales', 'monitoring', 'analysis'],
@@ -339,6 +363,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Discord community manager',
prompt:
'Create a knowledge base connected to my Google Docs or Notion with product documentation. Then build a workflow that monitors my Discord server for unanswered questions, answers them using the knowledge base, tracks common questions in a table, and sends a weekly community summary to Slack.',
integrationBlockTypes: ['discord', 'google_docs', 'notion', 'slack'],
modules: ['knowledge-base', 'tables', 'agent', 'scheduled', 'workflows'],
category: 'support',
tags: ['community', 'support', 'communication'],
@@ -348,6 +373,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Survey response analyzer',
prompt:
'Create a workflow that pulls new Typeform responses daily, categorizes feedback by theme and sentiment, logs structured results to a table, and sends a Slack digest when a new batch of responses comes in with the key takeaways.',
integrationBlockTypes: ['typeform', 'slack'],
modules: ['tables', 'scheduled', 'agent', 'workflows'],
category: 'support',
tags: ['product', 'analysis', 'reporting'],
@@ -357,6 +383,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Email knowledge search',
prompt:
'Create a knowledge base connected to my Gmail so all my emails are automatically synced, chunked, and searchable. Then build an agent I can ask things like "what did Sarah say about the pricing proposal?" or "find the contract John sent last month" and get instant answers with the original email cited.',
integrationBlockTypes: ['gmail'],
modules: ['knowledge-base', 'agent'],
category: 'support',
tags: ['individual', 'research', 'communication'],
@@ -366,6 +393,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Support ticket knowledge search',
prompt:
'Create a knowledge base connected to my Zendesk account so all past tickets, resolutions, and agent notes are automatically synced and searchable. Then build an agent my support team can ask things like "how do we usually resolve the SSO login issue?" or "has anyone reported this billing bug before?" to find past solutions instantly.',
integrationBlockTypes: ['zendesk'],
modules: ['knowledge-base', 'agent'],
category: 'support',
tags: ['support', 'research', 'team'],
@@ -377,6 +405,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Feature spec writer',
prompt:
'Create an agent that takes a rough feature idea or user story, researches how similar features work in competing products, and writes a complete product requirements document with user stories, acceptance criteria, edge cases, and technical considerations.',
integrationBlockTypes: [],
modules: ['agent', 'files', 'workflows'],
category: 'engineering',
tags: ['product', 'engineering', 'research', 'content'],
@@ -386,6 +415,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Jira knowledge search',
prompt:
'Create a knowledge base connected to my Jira project so all tickets, comments, and resolutions are automatically synced and searchable. Then build an agent I can ask things like "how did we fix the auth timeout issue?" or "what was decided about the API redesign?" and get answers with ticket citations.',
integrationBlockTypes: ['jira'],
modules: ['knowledge-base', 'agent'],
category: 'engineering',
tags: ['engineering', 'research'],
@@ -395,6 +425,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Linear knowledge search',
prompt:
'Create a knowledge base connected to my Linear workspace so all issues, comments, project updates, and decisions are automatically synced and searchable. Then build an agent I can ask things like "why did we deprioritize the mobile app?" or "what was the root cause of the checkout bug?" and get answers traced back to specific issues.',
integrationBlockTypes: ['linear'],
modules: ['knowledge-base', 'agent'],
category: 'engineering',
tags: ['engineering', 'research', 'product'],
@@ -404,6 +435,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Bug triage agent',
prompt:
'Build an agent that monitors Sentry for new errors, automatically triages them by severity and affected users, creates Linear tickets for critical issues with full stack traces, and sends a Slack notification to the on-call channel.',
integrationBlockTypes: ['sentry', 'linear', 'slack'],
modules: ['agent', 'workflows'],
category: 'engineering',
tags: ['engineering', 'devops', 'automation'],
@@ -413,6 +445,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'PR review assistant',
prompt:
'Create a knowledge base connected to my GitHub repo so it stays synced with my style guide and coding standards. Then build a workflow that reviews new pull requests against it, checks for common issues and security vulnerabilities, and posts a review comment with specific suggestions.',
integrationBlockTypes: ['github'],
modules: ['knowledge-base', 'agent', 'workflows'],
category: 'engineering',
tags: ['engineering', 'automation'],
@@ -422,6 +455,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Changelog generator',
prompt:
'Build a scheduled workflow that runs every Friday, pulls all merged PRs from GitHub for the week, categorizes changes as features, fixes, or improvements, and generates a user-facing changelog document with clear descriptions.',
integrationBlockTypes: ['github'],
modules: ['scheduled', 'agent', 'files', 'workflows'],
category: 'engineering',
tags: ['engineering', 'product', 'reporting', 'content'],
@@ -431,6 +465,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Incident postmortem writer',
prompt:
'Create a workflow that when triggered after an incident, pulls the Slack thread from the incident channel, gathers relevant Sentry errors and deployment logs, and drafts a structured postmortem with timeline, root cause, and action items.',
integrationBlockTypes: ['slack', 'sentry'],
modules: ['agent', 'files', 'workflows'],
category: 'engineering',
tags: ['engineering', 'devops', 'analysis'],
@@ -440,6 +475,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Documentation auto-updater',
prompt:
'Create a knowledge base connected to my GitHub repository so code and docs stay synced. Then build a scheduled weekly workflow that detects API changes, compares them against the knowledge base to find outdated documentation, and either updates Notion pages directly or creates Linear tickets for the needed changes.',
integrationBlockTypes: ['github', 'notion', 'linear'],
modules: ['scheduled', 'agent', 'workflows'],
category: 'engineering',
tags: ['engineering', 'sync', 'automation'],
@@ -449,6 +485,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Incident response coordinator',
prompt:
'Create a knowledge base connected to my Confluence or Notion with runbooks and incident procedures. Then build a workflow triggered by PagerDuty incidents that searches the runbooks, gathers related Datadog alerts, identifies the on-call rotation, and posts a comprehensive incident brief to Slack.',
integrationBlockTypes: ['confluence', 'notion', 'pagerduty', 'datadog', 'slack'],
modules: ['knowledge-base', 'agent', 'workflows'],
category: 'engineering',
tags: ['devops', 'engineering', 'automation'],
@@ -458,6 +495,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Sprint report generator',
prompt:
'Create a scheduled workflow that runs at the end of each sprint, pulls all completed, in-progress, and blocked Jira tickets, calculates velocity and carry-over, and generates a sprint summary document with charts and trends to share with the team.',
integrationBlockTypes: ['jira'],
modules: ['scheduled', 'agent', 'files', 'workflows'],
category: 'engineering',
tags: ['engineering', 'reporting', 'team'],
@@ -467,6 +505,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Knowledge base sync',
prompt:
'Create a knowledge base connected to my Confluence workspace so all wiki pages are automatically synced and searchable. Then build a scheduled workflow that identifies stale pages not updated in 90 days and sends a Slack reminder to page owners to review them.',
integrationBlockTypes: ['confluence', 'slack'],
modules: ['knowledge-base', 'scheduled', 'agent', 'workflows'],
category: 'engineering',
tags: ['engineering', 'sync', 'team'],
@@ -478,6 +517,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Long-form content writer',
prompt:
'Build a workflow that takes a topic or brief, researches it deeply across the web, generates a detailed outline, then writes a full long-form article with sections, examples, and a conclusion. Save the final draft as a document for review.',
integrationBlockTypes: [],
modules: ['agent', 'files', 'workflows'],
category: 'marketing',
tags: ['content', 'research', 'marketing'],
@@ -487,6 +527,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Case study generator',
prompt:
'Create a knowledge base from my customer data and interview notes, then build a workflow that generates a polished case study file with the challenge, solution, results, and a pull quote — formatted and ready to publish.',
integrationBlockTypes: [],
modules: ['knowledge-base', 'files', 'agent'],
category: 'marketing',
tags: ['marketing', 'content', 'sales'],
@@ -496,6 +537,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Social media content calendar',
prompt:
'Build a workflow that generates a full month of social media content for my brand. Research trending topics in my industry, create a table with post dates, platforms, copy drafts, and hashtags, then schedule a weekly refresh to keep the calendar filled with fresh ideas.',
integrationBlockTypes: [],
modules: ['tables', 'agent', 'scheduled', 'workflows'],
category: 'marketing',
tags: ['marketing', 'content', 'automation'],
@@ -505,6 +547,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Multi-language content translator',
prompt:
'Create a workflow that takes a document or blog post and translates it into multiple target languages while preserving tone, formatting, and brand voice. Save each translation as a separate file and flag sections that may need human review for cultural nuance.',
integrationBlockTypes: [],
modules: ['files', 'agent', 'workflows'],
category: 'marketing',
tags: ['content', 'enterprise', 'automation'],
@@ -514,6 +557,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Content repurposer',
prompt:
'Build a workflow that takes a YouTube video URL, pulls the video details and description, researches the topic on the web for additional context, and generates a Twitter thread, LinkedIn post, and blog summary optimized for each platform.',
integrationBlockTypes: ['youtube'],
modules: ['agent', 'files', 'workflows'],
category: 'marketing',
tags: ['marketing', 'content', 'automation'],
@@ -523,6 +567,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Social mention tracker',
prompt:
'Create a scheduled workflow that monitors Reddit and X for mentions of my brand and competitors, scores each mention by sentiment and reach, logs them to a table, and sends a daily Slack digest of notable mentions.',
integrationBlockTypes: ['reddit', 'x', 'slack'],
modules: ['tables', 'scheduled', 'agent', 'workflows'],
category: 'marketing',
tags: ['marketing', 'monitoring', 'analysis'],
@@ -532,6 +577,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'SEO content brief generator',
prompt:
'Build a workflow that takes a target keyword, scrapes the top 10 ranking pages, analyzes their content structure and subtopics, then generates a detailed content brief with outline, word count target, questions to answer, and internal linking suggestions.',
integrationBlockTypes: ['firecrawl'],
modules: ['agent', 'files', 'workflows'],
category: 'marketing',
tags: ['marketing', 'content', 'research'],
@@ -541,6 +587,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Newsletter curator',
prompt:
'Create a scheduled weekly workflow that scrapes my favorite industry news sites and blogs, picks the top stories relevant to my audience, writes summaries for each, and drafts a ready-to-send newsletter in Mailchimp.',
integrationBlockTypes: ['mailchimp'],
modules: ['scheduled', 'agent', 'files', 'workflows'],
category: 'marketing',
tags: ['marketing', 'content', 'communication'],
@@ -550,6 +597,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'LinkedIn content engine',
prompt:
'Build a workflow that scrapes my company blog for new posts, generates LinkedIn posts with hooks, insights, and calls-to-action optimized for engagement, and saves drafts as files for my review before posting to LinkedIn.',
integrationBlockTypes: ['linkedin'],
modules: ['agent', 'files', 'scheduled', 'workflows'],
category: 'marketing',
tags: ['marketing', 'content', 'automation'],
@@ -559,6 +607,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Blog auto-publisher',
prompt:
'Build a workflow that takes a draft document, optimizes it for SEO by researching target keywords, formats it for WordPress with proper headings and meta description, and publishes it as a draft post for final review.',
integrationBlockTypes: ['wordpress'],
modules: ['agent', 'files', 'workflows'],
category: 'marketing',
tags: ['marketing', 'content', 'automation'],
@@ -570,6 +619,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Personal knowledge assistant',
prompt:
'Create a knowledge base and connect it to my Google Drive, Notion, or Obsidian so all my notes, docs, and articles are automatically synced and embedded. Then build an agent that I can ask anything — it should answer with citations and deploy as a chat endpoint.',
integrationBlockTypes: ['google_drive', 'notion', 'obsidian'],
modules: ['knowledge-base', 'agent'],
category: 'productivity',
tags: ['individual', 'research', 'team'],
@@ -579,6 +629,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Slack knowledge search',
prompt:
'Create a knowledge base connected to my Slack workspace so all channel conversations and threads are automatically synced and searchable. Then build an agent I can ask things like "what did the team decide about the launch date?" or "what was the outcome of the design review?" and get answers with links to the original messages.',
integrationBlockTypes: ['slack'],
modules: ['knowledge-base', 'agent'],
category: 'productivity',
tags: ['team', 'research', 'communication'],
@@ -588,6 +639,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Notion knowledge search',
prompt:
'Create a knowledge base connected to my Notion workspace so all pages, databases, meeting notes, and wikis are automatically synced and searchable. Then build an agent I can ask things like "what\'s our refund policy?" or "what was decided in the Q3 planning doc?" and get instant answers with page links.',
integrationBlockTypes: ['notion'],
modules: ['knowledge-base', 'agent'],
category: 'productivity',
tags: ['team', 'research'],
@@ -597,6 +649,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Google Drive knowledge search',
prompt:
'Create a knowledge base connected to my Google Drive so all documents, spreadsheets, and presentations are automatically synced and searchable. Then build an agent I can ask things like "find the board deck from last quarter" or "what were the KPIs in the marketing plan?" and get answers with doc links.',
integrationBlockTypes: ['google_drive'],
modules: ['knowledge-base', 'agent'],
category: 'productivity',
tags: ['individual', 'team', 'research'],
@@ -606,6 +659,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Document summarizer',
prompt:
'Create a workflow that takes any uploaded document — PDF, contract, report, research paper — and generates a structured summary with key takeaways, action items, important dates, and a one-paragraph executive overview.',
integrationBlockTypes: [],
modules: ['files', 'agent', 'workflows'],
category: 'productivity',
tags: ['individual', 'analysis', 'team'],
@@ -615,6 +669,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Bulk data classifier',
prompt:
'Build a workflow that takes a table of unstructured data — support tickets, feedback, survey responses, leads, or any text — runs each row through an agent to classify, tag, score, and enrich it, then writes the structured results back to the table.',
integrationBlockTypes: [],
modules: ['tables', 'agent', 'workflows'],
category: 'productivity',
tags: ['analysis', 'automation', 'team'],
@@ -624,6 +679,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Automated narrative report',
prompt:
'Build a scheduled workflow that pulls key data from my tables every week, analyzes trends and anomalies, and writes a narrative report — not just charts and numbers, but written insights explaining what changed, why it matters, and what to do next. Save it as a document and send a summary to Slack.',
integrationBlockTypes: ['slack'],
modules: ['tables', 'scheduled', 'agent', 'files', 'workflows'],
category: 'productivity',
tags: ['founder', 'reporting', 'analysis'],
@@ -633,6 +689,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Investor update writer',
prompt:
'Build a workflow that pulls key metrics from my tables — revenue, growth, burn rate, headcount, milestones — and drafts a concise investor update with highlights, lowlights, asks, and KPIs. Save it as a file I can review before sending. Schedule it to run on the first of each month.',
integrationBlockTypes: [],
modules: ['tables', 'scheduled', 'agent', 'files', 'workflows'],
category: 'productivity',
tags: ['founder', 'reporting', 'communication'],
@@ -642,6 +699,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Email digest curator',
prompt:
'Create a scheduled daily workflow that searches the web for the latest articles, papers, and news on topics I care about, picks the top 5 most relevant pieces, writes a one-paragraph summary for each, and delivers a curated reading digest to my inbox or Slack.',
integrationBlockTypes: ['slack'],
modules: ['scheduled', 'agent', 'files', 'workflows'],
category: 'productivity',
tags: ['individual', 'research', 'content'],
@@ -651,6 +709,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Knowledge extractor',
prompt:
'Build a workflow that takes raw meeting notes, brainstorm dumps, or research transcripts, extracts the key insights, decisions, and facts, organizes them by topic, and saves them into my knowledge base so they are searchable and reusable in future conversations.',
integrationBlockTypes: [],
modules: ['files', 'knowledge-base', 'agent', 'workflows'],
category: 'productivity',
tags: ['individual', 'team', 'research'],
@@ -660,6 +719,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Weekly team digest',
prompt:
"Build a scheduled workflow that runs every Friday, pulls the week's GitHub commits, closed Linear issues, and key Slack conversations, then emails a formatted weekly summary to the team.",
integrationBlockTypes: ['github', 'linear', 'slack'],
modules: ['scheduled', 'agent', 'workflows'],
category: 'productivity',
tags: ['engineering', 'team', 'reporting'],
@@ -669,6 +729,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Daily standup summary',
prompt:
'Create a scheduled workflow that reads the #standup Slack channel each morning, summarizes what everyone is working on, identifies blockers, and posts a structured recap to a Google Doc.',
integrationBlockTypes: ['slack', 'google_docs'],
modules: ['scheduled', 'agent', 'files', 'workflows'],
category: 'productivity',
tags: ['team', 'reporting', 'communication'],
@@ -678,6 +739,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Email triage assistant',
prompt:
'Build a workflow that scans my Gmail inbox every hour, categorizes emails by urgency and type (action needed, FYI, follow-up), drafts replies for routine messages, and sends me a prioritized summary in Slack so I only open what matters. Schedule it to run hourly.',
integrationBlockTypes: ['gmail', 'slack'],
modules: ['agent', 'scheduled', 'workflows'],
category: 'productivity',
tags: ['individual', 'communication', 'automation'],
@@ -687,6 +749,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Meeting notes to action items',
prompt:
'Create a workflow that takes meeting notes or a transcript, extracts action items with owners and due dates, creates tasks in Linear or Asana for each one, and posts a summary to the relevant Slack channel.',
integrationBlockTypes: ['linear', 'asana', 'slack'],
modules: ['agent', 'workflows'],
category: 'productivity',
tags: ['team', 'automation'],
@@ -696,6 +759,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Weekly metrics report',
prompt:
'Build a scheduled workflow that pulls data from Stripe and my database every Monday, calculates key metrics like MRR, churn, new subscriptions, and failed payments, populates a Google Sheet, and Slacks the team a summary with week-over-week trends.',
integrationBlockTypes: ['stripe', 'google_sheets', 'slack'],
modules: ['scheduled', 'tables', 'agent', 'workflows'],
category: 'productivity',
tags: ['founder', 'finance', 'reporting'],
@@ -705,6 +769,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Product analytics digest',
prompt:
'Create a scheduled weekly workflow that pulls key product metrics from Amplitude — active users, feature adoption rates, retention cohorts, and top events — generates an executive summary with week-over-week trends, and posts it to Slack.',
integrationBlockTypes: ['amplitude', 'slack'],
modules: ['scheduled', 'agent', 'workflows'],
category: 'productivity',
tags: ['product', 'reporting', 'analysis'],
@@ -714,6 +779,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Scheduling follow-up automator',
prompt:
'Build a workflow that monitors new Calendly bookings, researches each attendee and their company, prepares a pre-meeting brief with relevant context, and sends a personalized confirmation email with an agenda and any prep materials.',
integrationBlockTypes: ['calendly'],
modules: ['agent', 'workflows'],
category: 'productivity',
tags: ['sales', 'research', 'automation'],
@@ -723,6 +789,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'SMS appointment reminders',
prompt:
'Create a scheduled workflow that checks Google Calendar each morning for appointments in the next 24 hours, and sends an SMS reminder to each attendee via Twilio with the meeting time, location, and any prep notes.',
integrationBlockTypes: ['google_calendar', 'twilio_sms'],
modules: ['scheduled', 'agent', 'workflows'],
category: 'productivity',
tags: ['individual', 'communication', 'automation'],
@@ -732,6 +799,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Microsoft Teams daily brief',
prompt:
'Build a scheduled workflow that pulls updates from your project tools — GitHub commits, Jira ticket status changes, and calendar events — and posts a formatted daily brief to your Microsoft Teams channel each morning.',
integrationBlockTypes: ['github', 'jira', 'microsoft_teams'],
modules: ['scheduled', 'agent', 'workflows'],
category: 'productivity',
tags: ['team', 'reporting', 'enterprise'],
@@ -743,6 +811,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Data cleanup agent',
prompt:
'Create a workflow that takes a messy table — inconsistent formatting, duplicates, missing fields, typos — and cleans it up by standardizing values, merging duplicates, filling gaps where possible, and flagging rows that need human review.',
integrationBlockTypes: [],
modules: ['tables', 'agent', 'workflows'],
category: 'operations',
tags: ['automation', 'analysis'],
@@ -752,6 +821,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Training material generator',
prompt:
'Create a knowledge base from my product documentation, then build a workflow that generates training materials from it — onboarding guides, FAQ documents, step-by-step tutorials, and quiz questions. Schedule it to regenerate weekly so materials stay current as docs change.',
integrationBlockTypes: [],
modules: ['knowledge-base', 'files', 'agent', 'scheduled'],
category: 'operations',
tags: ['hr', 'content', 'team', 'automation'],
@@ -761,6 +831,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'SOP generator',
prompt:
'Create an agent that takes a brief description of any business process — from employee onboarding to incident response to content publishing — and generates a detailed standard operating procedure document with numbered steps, responsible roles, decision points, and checklists.',
integrationBlockTypes: [],
modules: ['files', 'agent'],
category: 'operations',
tags: ['team', 'enterprise', 'content'],
@@ -770,6 +841,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Invoice processor',
prompt:
'Build a workflow that processes invoice PDFs from Gmail, extracts vendor name, amount, due date, and line items, then logs everything to a tracking table and sends a Slack alert for invoices due within 7 days.',
integrationBlockTypes: ['gmail', 'slack'],
modules: ['files', 'tables', 'agent', 'workflows'],
category: 'operations',
tags: ['finance', 'automation'],
@@ -779,6 +851,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Contract analyzer',
prompt:
'Create a knowledge base from my standard contract terms, then build a workflow that reviews uploaded contracts against it — extracting key clauses like payment terms, liability caps, and termination conditions, flagging deviations, and outputting a summary to a table.',
integrationBlockTypes: [],
modules: ['knowledge-base', 'files', 'tables', 'agent'],
category: 'operations',
tags: ['legal', 'analysis'],
@@ -788,6 +861,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Competitive intel monitor',
prompt:
'Build a scheduled workflow that scrapes competitor websites, pricing pages, and changelog pages weekly using Firecrawl, compares against previous snapshots, summarizes any changes, logs them to a tracking table, and sends a Slack alert for major updates.',
integrationBlockTypes: ['firecrawl', 'slack'],
modules: ['scheduled', 'tables', 'agent', 'workflows'],
category: 'operations',
tags: ['founder', 'product', 'monitoring', 'research'],
@@ -797,6 +871,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Revenue operations dashboard',
prompt:
'Create a scheduled daily workflow that pulls payment data from Stripe, calculates MRR, net revenue, failed payments, and new subscriptions, logs everything to a table with historical tracking, and sends a daily Slack summary with trends and anomalies.',
integrationBlockTypes: ['stripe', 'slack'],
modules: ['tables', 'scheduled', 'agent', 'workflows'],
category: 'operations',
tags: ['finance', 'founder', 'reporting', 'monitoring'],
@@ -806,6 +881,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'E-commerce order monitor',
prompt:
'Build a workflow that monitors Shopify orders, flags high-value or unusual orders for review, tracks fulfillment status in a table, and sends daily inventory and sales summaries to Slack with restock alerts when items run low.',
integrationBlockTypes: ['shopify', 'slack'],
modules: ['tables', 'scheduled', 'agent', 'workflows'],
category: 'operations',
tags: ['ecommerce', 'monitoring', 'reporting'],
@@ -815,6 +891,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Compliance document checker',
prompt:
'Create a knowledge base from my compliance requirements and policies, then build an agent that reviews uploaded policy documents and SOC 2 evidence against it, identifies gaps or outdated sections, and generates a remediation checklist file with priority levels.',
integrationBlockTypes: [],
modules: ['knowledge-base', 'files', 'agent'],
category: 'operations',
tags: ['legal', 'enterprise', 'analysis'],
@@ -824,6 +901,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'New hire onboarding automation',
prompt:
"Build a workflow that when triggered with a new hire's info, creates their accounts, sends a personalized welcome message in Slack, schedules 1:1s with their team on Google Calendar, shares relevant onboarding docs from the knowledge base, and tracks completion in a table.",
integrationBlockTypes: ['slack', 'google_calendar'],
modules: ['knowledge-base', 'tables', 'agent', 'workflows'],
category: 'operations',
tags: ['hr', 'automation', 'team'],
@@ -833,6 +911,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Candidate screening assistant',
prompt:
'Create a knowledge base from my job descriptions and hiring criteria, then build a workflow that takes uploaded resumes, evaluates candidates against the requirements, scores them on experience, skills, and culture fit, and populates a comparison table with a summary and recommendation for each.',
integrationBlockTypes: [],
modules: ['knowledge-base', 'files', 'tables', 'agent'],
category: 'operations',
tags: ['hr', 'recruiting', 'analysis'],
@@ -842,6 +921,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Recruiting pipeline automator',
prompt:
'Build a scheduled workflow that syncs open jobs and candidates from Greenhouse to a tracking table daily, flags candidates who have been in the same stage for more than 5 days, and sends a Slack summary to hiring managers with pipeline stats and bottlenecks.',
integrationBlockTypes: ['greenhouse', 'slack'],
modules: ['tables', 'scheduled', 'agent', 'workflows'],
category: 'operations',
tags: ['hr', 'recruiting', 'monitoring', 'reporting'],
@@ -851,6 +931,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Infrastructure health report',
prompt:
'Create a scheduled daily workflow that queries Datadog for key infrastructure metrics — error rates, latency percentiles, CPU and memory usage — logs them to a table for trend tracking, and sends a morning Slack report highlighting any anomalies or degradations.',
integrationBlockTypes: ['datadog', 'slack'],
modules: ['tables', 'scheduled', 'agent', 'workflows'],
category: 'operations',
tags: ['devops', 'infrastructure', 'monitoring', 'reporting'],
@@ -860,6 +941,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Airtable data sync',
prompt:
'Create a scheduled workflow that syncs records from my Airtable base into a Sim table every hour, keeping both in sync. Use an agent to detect changes, resolve conflicts, and flag any discrepancies for review in Slack.',
integrationBlockTypes: ['airtable', 'slack'],
modules: ['tables', 'scheduled', 'agent', 'workflows'],
category: 'operations',
tags: ['sync', 'automation'],
@@ -869,6 +951,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Multi-source knowledge hub',
prompt:
'Create a knowledge base and connect it to Confluence, Notion, and Google Drive so all my company documentation is automatically synced, chunked, and embedded. Then deploy a Q&A agent that can answer questions across all sources with citations.',
integrationBlockTypes: ['confluence', 'notion', 'google_drive'],
modules: ['knowledge-base', 'scheduled', 'agent', 'workflows'],
category: 'operations',
tags: ['enterprise', 'team', 'sync', 'automation'],
@@ -878,6 +961,7 @@ export const TEMPLATES: TemplatePrompt[] = [
title: 'Customer 360 view',
prompt:
'Create a comprehensive customer table that aggregates data from my CRM, support tickets, billing history, and product usage into a single unified view per customer. Schedule it to sync daily and send a Slack alert when any customer shows signs of trouble across multiple signals.',
integrationBlockTypes: ['slack'],
modules: ['tables', 'scheduled', 'agent', 'workflows'],
category: 'operations',
tags: ['founder', 'sales', 'support', 'enterprise', 'sync'],

View File

@@ -202,9 +202,7 @@ export function UserInput({
}
useEffect(() => {
if (editValue) {
onEditValueConsumed?.()
}
if (editValue) onEditValueConsumed?.()
}, [editValue, onEditValueConsumed])
const animatedPlaceholder = useAnimatedPlaceholder(isInitialView)

View File

@@ -13,9 +13,9 @@ import {
LandingWorkflowSeedStorage,
} from '@/lib/core/utils/browser-storage'
import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export'
import { MessageActions } from '@/app/workspace/[workspaceId]/components'
import { useChatHistory, useMarkTaskRead } from '@/hooks/queries/tasks'
import type { ChatContext } from '@/stores/panel'
import { useSidebarStore } from '@/stores/sidebar/store'
import {
MessageContent,
MothershipView,
@@ -25,7 +25,7 @@ import {
UserMessageContent,
} from './components'
import { PendingTagIndicator } from './components/message-content/components/special-tags'
import { useAutoScroll, useChat } from './hooks'
import { useAutoScroll, useChat, useMothershipResize } from './hooks'
import type { FileAttachmentForApi, MothershipResource, MothershipResourceType } from './types'
const logger = createLogger('Home')
@@ -137,26 +137,39 @@ export function Home({ chatId }: HomeProps = {}) {
useChatHistory(chatId)
const { mutate: markRead } = useMarkTaskRead(workspaceId)
const { mothershipRef, handleResizePointerDown, clearWidth } = useMothershipResize()
const [isResourceCollapsed, setIsResourceCollapsed] = useState(true)
const [isResourceAnimatingIn, setIsResourceAnimatingIn] = useState(false)
const [skipResourceTransition, setSkipResourceTransition] = useState(false)
const isResourceCollapsedRef = useRef(isResourceCollapsed)
isResourceCollapsedRef.current = isResourceCollapsed
const collapseResource = useCallback(() => setIsResourceCollapsed(true), [])
const collapseResource = useCallback(() => {
clearWidth()
setIsResourceCollapsed(true)
}, [clearWidth])
const animatingInTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const startAnimatingIn = useCallback(() => {
if (animatingInTimerRef.current) clearTimeout(animatingInTimerRef.current)
setIsResourceAnimatingIn(true)
animatingInTimerRef.current = setTimeout(() => {
setIsResourceAnimatingIn(false)
animatingInTimerRef.current = null
}, 400)
}, [])
const expandResource = useCallback(() => {
setIsResourceCollapsed(false)
setIsResourceAnimatingIn(true)
}, [])
startAnimatingIn()
}, [startAnimatingIn])
const handleResourceEvent = useCallback(() => {
if (isResourceCollapsedRef.current) {
const { isCollapsed, toggleCollapsed } = useSidebarStore.getState()
if (!isCollapsed) toggleCollapsed()
setIsResourceCollapsed(false)
setIsResourceAnimatingIn(true)
startAnimatingIn()
}
}, [])
}, [startAnimatingIn])
const {
messages,
@@ -177,8 +190,15 @@ export function Home({ chatId }: HomeProps = {}) {
} = useChat(workspaceId, chatId, { onResourceEvent: handleResourceEvent })
const [editingInputValue, setEditingInputValue] = useState('')
const [prevChatId, setPrevChatId] = useState(chatId)
const clearEditingValue = useCallback(() => setEditingInputValue(''), [])
// Clear editing value when navigating to a different chat (guarded render-phase update)
if (chatId !== prevChatId) {
setPrevChatId(chatId)
setEditingInputValue('')
}
const handleEditQueuedMessage = useCallback(
(id: string) => {
const msg = editQueuedMessage(id)
@@ -189,10 +209,6 @@ export function Home({ chatId }: HomeProps = {}) {
[editQueuedMessage]
)
useEffect(() => {
setEditingInputValue('')
}, [chatId])
useEffect(() => {
wasSendingRef.current = false
if (resolvedChatId) markRead(resolvedChatId)
@@ -206,23 +222,12 @@ export function Home({ chatId }: HomeProps = {}) {
}, [isSending, resolvedChatId, markRead])
useEffect(() => {
if (!isResourceAnimatingIn) return
const timer = setTimeout(() => setIsResourceAnimatingIn(false), 400)
return () => clearTimeout(timer)
}, [isResourceAnimatingIn])
useEffect(() => {
if (resources.length > 0 && isResourceCollapsedRef.current) {
setSkipResourceTransition(true)
setIsResourceCollapsed(false)
}
}, [resources])
useEffect(() => {
if (!skipResourceTransition) return
if (!(resources.length > 0 && isResourceCollapsedRef.current)) return
setIsResourceCollapsed(false)
setSkipResourceTransition(true)
const id = requestAnimationFrame(() => setSkipResourceTransition(false))
return () => cancelAnimationFrame(id)
}, [skipResourceTransition])
}, [resources])
const handleSubmit = useCallback(
(text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => {
@@ -358,7 +363,7 @@ export function Home({ chatId }: HomeProps = {}) {
return (
<div className='relative flex h-full bg-[var(--bg)]'>
<div className='flex h-full min-w-0 flex-1 flex-col'>
<div className='flex h-full min-w-[320px] flex-1 flex-col'>
<div
ref={scrollContainerRef}
className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8 [scrollbar-gutter:stable]'
@@ -414,7 +419,12 @@ export function Home({ chatId }: HomeProps = {}) {
const isLastMessage = index === messages.length - 1
return (
<div key={msg.id} className='pb-4'>
<div key={msg.id} className='group/msg relative pb-5'>
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
<div className='absolute right-0 bottom-0 z-10'>
<MessageActions content={msg.content} requestId={msg.requestId} />
</div>
)}
<MessageContent
blocks={msg.contentBlocks || []}
fallbackContent={msg.content}
@@ -452,7 +462,21 @@ export function Home({ chatId }: HomeProps = {}) {
</div>
</div>
{/* Resize handle — zero-width flex child whose absolute child straddles the border */}
{!isResourceCollapsed && (
<div className='relative z-20 w-0 flex-none'>
<div
className='absolute inset-y-0 left-[-4px] w-[8px] cursor-ew-resize'
role='separator'
aria-orientation='vertical'
aria-label='Resize resource panel'
onPointerDown={handleResizePointerDown}
/>
</div>
)}
<MothershipView
ref={mothershipRef}
workspaceId={workspaceId}
chatId={resolvedChatId}
resources={resources}

View File

@@ -2,4 +2,5 @@ export { useAnimatedPlaceholder } from './use-animated-placeholder'
export { useAutoScroll } from './use-auto-scroll'
export type { UseChatReturn } from './use-chat'
export { useChat } from './use-chat'
export { useMothershipResize } from './use-mothership-resize'
export { useStreamingReveal } from './use-streaming-reveal'

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { usePathname } from 'next/navigation'
@@ -132,7 +132,7 @@ function toDisplayAttachment(f: TaskStoredFileAttachment): ChatMessageAttachment
media_type: f.media_type,
size: f.size,
previewUrl: f.media_type.startsWith('image/')
? `/api/files/serve/${encodeURIComponent(f.key)}?context=copilot`
? `/api/files/serve/${encodeURIComponent(f.key)}?context=mothership`
: undefined,
}
}
@@ -142,6 +142,7 @@ function mapStoredMessage(msg: TaskStoredMessage): ChatMessage {
id: msg.id,
role: msg.role,
content: msg.content,
...(msg.requestId ? { requestId: msg.requestId } : {}),
}
const hasContentBlocks = Array.isArray(msg.contentBlocks) && msg.contentBlocks.length > 0
@@ -268,14 +269,22 @@ export function useChat(
onResourceEventRef.current = options?.onResourceEvent
const resourcesRef = useRef(resources)
resourcesRef.current = resources
const activeResourceIdRef = useRef(activeResourceId)
activeResourceIdRef.current = activeResourceId
// Derive the effective active resource ID — auto-selects the last resource when the stored ID is
// absent or no longer in the list, avoiding a separate Effect-based state correction loop.
const effectiveActiveResourceId = useMemo(() => {
if (resources.length === 0) return null
if (activeResourceId && resources.some((r) => r.id === activeResourceId))
return activeResourceId
return resources[resources.length - 1].id
}, [resources, activeResourceId])
const activeResourceIdRef = useRef(effectiveActiveResourceId)
activeResourceIdRef.current = effectiveActiveResourceId
const [messageQueue, setMessageQueue] = useState<QueuedMessage[]>([])
const messageQueueRef = useRef<QueuedMessage[]>([])
useEffect(() => {
messageQueueRef.current = messageQueue
}, [messageQueue])
messageQueueRef.current = messageQueue
const sendMessageRef = useRef<UseChatReturn['sendMessage']>(async () => {})
const processSSEStreamRef = useRef<
@@ -481,19 +490,6 @@ export function useChat(
}
}, [chatHistory, workspaceId, queryClient])
useEffect(() => {
if (resources.length === 0) {
if (activeResourceId !== null) {
setActiveResourceId(null)
}
return
}
if (!activeResourceId || !resources.some((resource) => resource.id === activeResourceId)) {
setActiveResourceId(resources[resources.length - 1].id)
}
}, [activeResourceId, resources])
const processSSEStream = useCallback(
async (
reader: ReadableStreamDefaultReader<Uint8Array>,
@@ -509,6 +505,7 @@ export function useChat(
let activeSubagent: string | undefined
let runningText = ''
let lastContentSource: 'main' | 'subagent' | null = null
let streamRequestId: string | undefined
streamingContentRef.current = ''
streamingBlocksRef.current = []
@@ -526,14 +523,21 @@ export function useChat(
const flush = () => {
if (isStale()) return
streamingBlocksRef.current = [...blocks]
const snapshot = { content: runningText, contentBlocks: [...blocks] }
const snapshot: Partial<ChatMessage> = {
content: runningText,
contentBlocks: [...blocks],
}
if (streamRequestId) snapshot.requestId = streamRequestId
setMessages((prev) => {
if (expectedGen !== undefined && streamGenRef.current !== expectedGen) return prev
const idx = prev.findIndex((m) => m.id === assistantId)
if (idx >= 0) {
return prev.map((m) => (m.id === assistantId ? { ...m, ...snapshot } : m))
}
return [...prev, { id: assistantId, role: 'assistant' as const, ...snapshot }]
return [
...prev,
{ id: assistantId, role: 'assistant' as const, content: '', ...snapshot },
]
})
}
@@ -597,6 +601,14 @@ export function useChat(
}
break
}
case 'request_id': {
const rid = typeof parsed.data === 'string' ? parsed.data : undefined
if (rid) {
streamRequestId = rid
flush()
}
break
}
case 'content': {
const chunk = typeof parsed.data === 'string' ? parsed.data : (parsed.content ?? '')
if (chunk) {
@@ -854,9 +866,7 @@ export function useChat(
},
[workspaceId, queryClient, addResource, removeResource]
)
useLayoutEffect(() => {
processSSEStreamRef.current = processSSEStream
})
processSSEStreamRef.current = processSSEStream
const persistPartialResponse = useCallback(async () => {
const chatId = chatIdRef.current
@@ -945,9 +955,7 @@ export function useChat(
},
[invalidateChatQueries]
)
useLayoutEffect(() => {
finalizeRef.current = finalize
})
finalizeRef.current = finalize
const sendMessage = useCallback(
async (message: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => {
@@ -1083,9 +1091,7 @@ export function useChat(
},
[workspaceId, queryClient, processSSEStream, finalize]
)
useLayoutEffect(() => {
sendMessageRef.current = sendMessage
})
sendMessageRef.current = sendMessage
const stopGeneration = useCallback(async () => {
if (sendingRef.current && !chatIdRef.current) {
@@ -1223,7 +1229,7 @@ export function useChat(
sendMessage,
stopGeneration,
resources,
activeResourceId,
activeResourceId: effectiveActiveResourceId,
setActiveResourceId,
addResource,
removeResource,

View File

@@ -0,0 +1,101 @@
import { useCallback, useEffect, useRef } from 'react'
import { MOTHERSHIP_WIDTH } from '@/stores/constants'
/**
* Hook for managing resize of the MothershipView resource panel.
*
* Uses imperative DOM manipulation (zero React re-renders during drag) with
* Pointer Events + setPointerCapture for unified mouse/touch/stylus support.
* Attach `mothershipRef` to the MothershipView root div and bind
* `handleResizePointerDown` to the drag handle's onPointerDown.
* Call `clearWidth` when the panel collapses so the CSS class retakes control.
*/
export function useMothershipResize() {
const mothershipRef = useRef<HTMLDivElement | null>(null)
// Stored so the useEffect cleanup can tear down listeners if the component unmounts mid-drag
const cleanupRef = useRef<(() => void) | null>(null)
const handleResizePointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault()
const el = mothershipRef.current
if (!el) return
const handle = e.currentTarget as HTMLElement
handle.setPointerCapture(e.pointerId)
// Pin to current rendered width so drag starts from the visual position
el.style.width = `${el.getBoundingClientRect().width}px`
// Disable CSS transition to prevent animation lag during drag
const prevTransition = el.style.transition
el.style.transition = 'none'
document.body.style.cursor = 'ew-resize'
document.body.style.userSelect = 'none'
// AbortController removes all listeners at once on cleanup/cancel/unmount
const ac = new AbortController()
const { signal } = ac
const cleanup = () => {
ac.abort()
el.style.transition = prevTransition
document.body.style.cursor = ''
document.body.style.userSelect = ''
cleanupRef.current = null
}
cleanupRef.current = cleanup
handle.addEventListener(
'pointermove',
(moveEvent: PointerEvent) => {
const newWidth = window.innerWidth - moveEvent.clientX
const maxWidth = window.innerWidth * MOTHERSHIP_WIDTH.MAX_PERCENTAGE
el.style.width = `${Math.min(Math.max(newWidth, MOTHERSHIP_WIDTH.MIN), maxWidth)}px`
},
{ signal }
)
handle.addEventListener(
'pointerup',
(upEvent: PointerEvent) => {
handle.releasePointerCapture(upEvent.pointerId)
cleanup()
},
{ signal }
)
// Browser fires pointercancel when it reclaims the gesture (scroll, palm rejection, etc.)
// Without this, body cursor/userSelect and transition would be permanently stuck
handle.addEventListener('pointercancel', cleanup, { signal })
}, [])
// Tear down any active drag if the component unmounts mid-drag
useEffect(() => {
return () => {
cleanupRef.current?.()
}
}, [])
// Re-clamp panel width when the viewport is resized (inline px width can exceed max after narrowing)
useEffect(() => {
const handleWindowResize = () => {
const el = mothershipRef.current
if (!el || !el.style.width) return
const maxWidth = window.innerWidth * MOTHERSHIP_WIDTH.MAX_PERCENTAGE
const current = el.getBoundingClientRect().width
if (current > maxWidth) {
el.style.width = `${maxWidth}px`
}
}
window.addEventListener('resize', handleWindowResize)
return () => window.removeEventListener('resize', handleWindowResize)
}, [])
/** Remove inline width so the collapse CSS class retakes control */
const clearWidth = useCallback(() => {
mothershipRef.current?.style.removeProperty('width')
}, [])
return { mothershipRef, handleResizePointerDown, clearWidth }
}

View File

@@ -33,6 +33,7 @@ export interface QueuedMessage {
*/
export type SSEEventType =
| 'chat_id'
| 'request_id'
| 'title_updated'
| 'content'
| 'reasoning' // openai reasoning - render as thinking text
@@ -199,6 +200,7 @@ export interface ChatMessage {
contentBlocks?: ContentBlock[]
attachments?: ChatMessageAttachment[]
contexts?: ChatMessageContext[]
requestId?: string
}
export const SUBAGENT_LABELS: Record<SubagentName, string> = {

View File

@@ -169,16 +169,13 @@ export function ChunkEditor({
const saveFunction = isCreateMode ? handleSave : saveImmediately
useEffect(() => {
if (saveRef) {
saveRef.current = saveFunction
}
return () => {
if (saveRef) {
saveRef.current = null
}
}
}, [saveRef, saveFunction])
if (saveRef) saveRef.current = saveFunction
useEffect(
() => () => {
if (saveRef) saveRef.current = null
},
[saveRef]
)
const tokenStrings = useMemo(() => {
if (!tokenizerOn || !editedContent) return []

View File

@@ -274,9 +274,7 @@ export function KnowledgeBase({
const { data: connectors = [], isLoading: isLoadingConnectors } = useConnectorList(id)
const hasSyncingConnectors = connectors.some((c) => c.status === 'syncing')
const hasSyncingConnectorsRef = useRef(hasSyncingConnectors)
useEffect(() => {
hasSyncingConnectorsRef.current = hasSyncingConnectors
}, [hasSyncingConnectors])
hasSyncingConnectorsRef.current = hasSyncingConnectors
const {
documents,
@@ -752,11 +750,9 @@ export function KnowledgeBase({
const prevKnowledgeBaseIdRef = useRef<string>(id)
const isNavigatingToNewKB = prevKnowledgeBaseIdRef.current !== id
useEffect(() => {
if (knowledgeBase && knowledgeBase.id === id) {
prevKnowledgeBaseIdRef.current = id
}
}, [knowledgeBase, id])
if (knowledgeBase && knowledgeBase.id === id) {
prevKnowledgeBaseIdRef.current = id
}
const isInitialLoad = isLoadingKnowledgeBase && !knowledgeBase
const isFetchingNewKB = isNavigatingToNewKB && isFetchingDocuments

View File

@@ -1,6 +1,6 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Loader2, RotateCcw, X } from 'lucide-react'
import { useParams } from 'next/navigation'
@@ -75,15 +75,25 @@ export function AddDocumentsModal({
}
}, [open, clearError])
/** Handles close with upload guard */
const handleOpenChange = useCallback(
(newOpen: boolean) => {
if (!newOpen) {
if (isUploading) return
setFiles([])
setFileError(null)
clearError()
setIsDragging(false)
setDragCounter(0)
setRetryingIndexes(new Set())
}
onOpenChange(newOpen)
},
[isUploading, clearError, onOpenChange]
)
const handleClose = () => {
if (isUploading) return
setFiles([])
setFileError(null)
clearError()
setIsDragging(false)
setDragCounter(0)
setRetryingIndexes(new Set())
onOpenChange(false)
handleOpenChange(false)
}
const processFiles = async (fileList: FileList | File[]) => {
@@ -220,7 +230,7 @@ export function AddDocumentsModal({
}
return (
<Modal open={open} onOpenChange={handleClose}>
<Modal open={open} onOpenChange={handleOpenChange}>
<ModalContent size='md'>
<ModalHeader>New Documents</ModalHeader>

View File

@@ -220,10 +220,7 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) {
return result
}, [rawExecutions])
useEffect(() => {
prevExecutionsRef.current = executions
}, [executions])
prevExecutionsRef.current = executions
const lastExecutionByWorkflow = useMemo(() => {
const map = new Map<string, number>()

View File

@@ -31,7 +31,8 @@ export function Admin() {
const [workflowId, setWorkflowId] = useState('')
const [usersOffset, setUsersOffset] = useState(0)
const [usersEnabled, setUsersEnabled] = useState(false)
const [searchInput, setSearchInput] = useState('')
const [searchQuery, setSearchQuery] = useState('')
const [banUserId, setBanUserId] = useState<string | null>(null)
const [banReason, setBanReason] = useState('')
@@ -39,8 +40,12 @@ export function Admin() {
data: usersData,
isLoading: usersLoading,
error: usersError,
refetch: refetchUsers,
} = useAdminUsers(usersOffset, PAGE_SIZE, usersEnabled)
} = useAdminUsers(usersOffset, PAGE_SIZE, searchQuery)
const handleSearch = () => {
setUsersOffset(0)
setSearchQuery(searchInput.trim())
}
const totalPages = useMemo(
() => Math.ceil((usersData?.total ?? 0) / PAGE_SIZE),
@@ -62,14 +67,6 @@ export function Admin() {
)
}
const handleLoadUsers = () => {
if (usersEnabled) {
refetchUsers()
} else {
setUsersEnabled(true)
}
}
const pendingUserIds = useMemo(() => {
const ids = new Set<string>()
if (setUserRole.isPending && (setUserRole.variables as { userId?: string })?.userId)
@@ -136,10 +133,16 @@ export function Admin() {
<div className='h-px bg-[var(--border-secondary)]' />
<div className='flex flex-col gap-[12px]'>
<div className='flex items-center justify-between'>
<p className='font-medium text-[14px] text-[var(--text-primary)]'>User Management</p>
<Button variant='active' onClick={handleLoadUsers} disabled={usersLoading}>
{usersLoading ? 'Loading...' : usersEnabled ? 'Refresh' : 'Load Users'}
<p className='font-medium text-[14px] text-[var(--text-primary)]'>User Management</p>
<div className='flex gap-[8px]'>
<EmcnInput
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder='Search by email or paste a user ID...'
/>
<Button variant='primary' onClick={handleSearch} disabled={usersLoading}>
{usersLoading ? 'Searching...' : 'Search'}
</Button>
</div>
@@ -164,9 +167,9 @@ export function Admin() {
</div>
)}
{usersData && (
{searchQuery.length > 0 && usersData && (
<>
<div className='flex flex-col gap-[2px] rounded-[8px] border border-[var(--border-secondary)]'>
<div className='flex flex-col gap-[2px]'>
<div className='flex items-center gap-[12px] border-[var(--border-secondary)] border-b px-[12px] py-[8px] text-[12px] text-[var(--text-tertiary)]'>
<span className='w-[200px]'>Name</span>
<span className='flex-1'>Email</span>
@@ -176,7 +179,7 @@ export function Admin() {
</div>
{usersData.users.length === 0 && (
<div className='px-[12px] py-[16px] text-center text-[13px] text-[var(--text-tertiary)]'>
<div className='py-[16px] text-center text-[13px] text-[var(--text-tertiary)]'>
No users found.
</div>
)}

View File

@@ -1,6 +1,6 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Info, Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
@@ -64,13 +64,19 @@ export function ApiKeys() {
const [deleteKey, setDeleteKey] = useState<ApiKey | null>(null)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const [shouldScrollToBottom, setShouldScrollToBottom] = useState(false)
const defaultKeyType = allowPersonalApiKeys ? 'personal' : 'workspace'
const createButtonDisabled = isLoading || (!allowPersonalApiKeys && !canManageWorkspaceKeys)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const scrollToBottom = useCallback(() => {
scrollContainerRef.current?.scrollTo({
top: scrollContainerRef.current.scrollHeight,
behavior: 'smooth',
})
}, [])
const filteredWorkspaceKeys = useMemo(() => {
if (!searchTerm.trim()) {
return workspaceKeys.map((key, index) => ({ key, originalIndex: index }))
@@ -111,16 +117,6 @@ export function ApiKeys() {
}
}
useEffect(() => {
if (shouldScrollToBottom && scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
top: scrollContainerRef.current.scrollHeight,
behavior: 'smooth',
})
setShouldScrollToBottom(false)
}
}, [shouldScrollToBottom])
const formatLastUsed = (dateString?: string) => {
if (!dateString) return 'Never'
return formatDate(new Date(dateString))

View File

@@ -316,6 +316,9 @@ export function CredentialsManager() {
// --- Detail view state ---
const [selectedCredentialId, setSelectedCredentialId] = useState<string | null>(null)
const [prevSelectedCredentialId, setPrevSelectedCredentialId] = useState<
string | null | undefined
>(undefined)
const [selectedDisplayNameDraft, setSelectedDisplayNameDraft] = useState('')
const [selectedDescriptionDraft, setSelectedDescriptionDraft] = useState('')
const [copyIdSuccess, setCopyIdSuccess] = useState(false)
@@ -347,6 +350,19 @@ export function CredentialsManager() {
[envCredentials, selectedCredentialId]
)
if (selectedCredential?.id !== prevSelectedCredentialId) {
setPrevSelectedCredentialId(selectedCredential?.id ?? null)
if (!selectedCredential) {
setSelectedDescriptionDraft('')
setSelectedDisplayNameDraft('')
setDetailsError(null)
} else {
setDetailsError(null)
setSelectedDescriptionDraft(selectedCredential.description || '')
setSelectedDisplayNameDraft(selectedCredential.displayName)
}
}
// --- Detail view hooks ---
const { data: members = [], isPending: membersLoading } = useWorkspaceCredentialMembers(
selectedCredential?.id
@@ -458,12 +474,10 @@ export function CredentialsManager() {
return personalInvalid || workspaceInvalid
}, [envVars, newWorkspaceRows])
// --- Effects ---
useEffect(() => {
hasChangesRef.current = hasChanges
shouldBlockNavRef.current = hasChanges || isDetailsDirty
}, [hasChanges, isDetailsDirty])
hasChangesRef.current = hasChanges
shouldBlockNavRef.current = hasChanges || isDetailsDirty
// --- Effects ---
useEffect(() => {
if (hasSavedRef.current) return
@@ -480,13 +494,12 @@ export function CredentialsManager() {
}, [variables])
useEffect(() => {
if (workspaceEnvData) {
if (hasSavedRef.current) {
hasSavedRef.current = false
} else {
setWorkspaceVars(workspaceEnvData?.workspace || {})
initialWorkspaceVarsRef.current = workspaceEnvData?.workspace || {}
}
if (!workspaceEnvData) return
if (hasSavedRef.current) {
hasSavedRef.current = false
} else {
setWorkspaceVars(workspaceEnvData.workspace || {})
initialWorkspaceVarsRef.current = workspaceEnvData.workspace || {}
}
}, [workspaceEnvData])
@@ -549,19 +562,6 @@ export function CredentialsManager() {
}
}, [])
// --- Detail view: sync drafts when credential changes ---
useEffect(() => {
if (!selectedCredential) {
setSelectedDescriptionDraft('')
setSelectedDisplayNameDraft('')
return
}
setDetailsError(null)
setSelectedDescriptionDraft(selectedCredential.description || '')
setSelectedDisplayNameDraft(selectedCredential.displayName)
}, [selectedCredential])
// --- Pending credential create request ---
const applyPendingCredentialCreateRequest = useCallback(
(request: PendingCredentialCreateRequest) => {

View File

@@ -68,6 +68,12 @@ export function General() {
const [name, setName] = useState(profile?.name || '')
const [isEditingName, setIsEditingName] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const [prevProfileName, setPrevProfileName] = useState(profile?.name)
if (profile?.name && profile.name !== prevProfileName) {
setPrevProfileName(profile.name)
setName(profile.name)
}
const [showResetPasswordModal, setShowResetPasswordModal] = useState(false)
const resetPassword = useResetPassword()
@@ -76,12 +82,6 @@ export function General() {
const snapToGridValue = settings?.snapToGridSize ?? 0
useEffect(() => {
if (profile?.name) {
setName(profile.name)
}
}, [profile?.name])
const {
previewUrl: profilePictureUrl,
fileInputRef: profilePictureInputRef,

View File

@@ -3,6 +3,7 @@
import { type FC, memo, useCallback, useMemo, useRef, useState } from 'react'
import { RotateCcw } from 'lucide-react'
import { Button } from '@/components/emcn'
import { MessageActions } from '@/app/workspace/[workspaceId]/components'
import {
OptionsSelector,
parseSpecialTags,
@@ -409,10 +410,15 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
if (isAssistant) {
return (
<div
className={`w-full max-w-full flex-none overflow-hidden [max-width:var(--panel-max-width)] ${isDimmed ? 'opacity-40' : 'opacity-100'}`}
className={`group/msg relative w-full max-w-full flex-none overflow-hidden [max-width:var(--panel-max-width)] ${isDimmed ? 'opacity-40' : 'opacity-100'}`}
style={{ '--panel-max-width': `${panelWidth - 16}px` } as React.CSSProperties}
>
<div className='max-w-full space-y-[4px] px-[2px] pb-[4px]'>
{!isStreaming && (message.content || message.contentBlocks?.length) && (
<div className='absolute right-0 bottom-0 z-10'>
<MessageActions content={message.content} requestId={message.requestId} />
</div>
)}
<div className='max-w-full space-y-[4px] px-[2px] pb-5'>
{/* Content blocks in chronological order */}
{memoizedContentBlocks || (isStreaming && <div className='min-h-0' />)}

View File

@@ -97,16 +97,14 @@ const PlanModeSection: React.FC<PlanModeSectionProps> = ({
const [isResizing, setIsResizing] = React.useState(false)
const [isEditing, setIsEditing] = React.useState(false)
const [editedContent, setEditedContent] = React.useState(content)
const [prevContent, setPrevContent] = React.useState(content)
if (!isEditing && content !== prevContent) {
setPrevContent(content)
setEditedContent(content)
}
const resizeStartRef = React.useRef({ y: 0, startHeight: 0 })
const textareaRef = React.useRef<HTMLTextAreaElement>(null)
// Update edited content when content prop changes
React.useEffect(() => {
if (!isEditing) {
setEditedContent(content)
}
}, [content, isEditing])
const handleResizeStart = React.useCallback(
(e: React.MouseEvent) => {
e.preventDefault()

View File

@@ -1,6 +1,6 @@
'use client'
import { memo, useEffect, useState } from 'react'
import { memo, useState } from 'react'
import { Check, ChevronDown, ChevronRight, Loader2, X } from 'lucide-react'
import { Button } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
@@ -47,13 +47,11 @@ export const TodoList = memo(function TodoList({
className,
}: TodoListProps) {
const [isCollapsed, setIsCollapsed] = useState(collapsed)
/**
* Sync collapsed prop with internal state
*/
useEffect(() => {
const [prevCollapsed, setPrevCollapsed] = useState(collapsed)
if (collapsed !== prevCollapsed) {
setPrevCollapsed(collapsed)
setIsCollapsed(collapsed)
}, [collapsed])
}
if (!todos || todos.length === 0) {
return null

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import {
escapeRegex,
filterOutContext,
@@ -22,15 +22,6 @@ interface UseContextManagementProps {
*/
export function useContextManagement({ message, initialContexts }: UseContextManagementProps) {
const [selectedContexts, setSelectedContexts] = useState<ChatContext[]>(initialContexts ?? [])
const initializedRef = useRef(false)
// Initialize with initial contexts when they're first provided (for edit mode)
useEffect(() => {
if (initialContexts && initialContexts.length > 0 && !initializedRef.current) {
setSelectedContexts(initialContexts)
initializedRef.current = true
}
}, [initialContexts])
/**
* Adds a context to the selected contexts list, avoiding duplicates

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import {
Button,
@@ -49,7 +49,10 @@ export function GeneralDeploy({
onLoadDeploymentComplete,
}: GeneralDeployProps) {
const [selectedVersion, setSelectedVersion] = useState<number | null>(null)
const [previewMode, setPreviewMode] = useState<PreviewMode>('active')
const [showActiveDespiteSelection, setShowActiveDespiteSelection] = useState(false)
// Derived — no useEffect needed
const previewMode: PreviewMode =
selectedVersion !== null && !showActiveDespiteSelection ? 'selected' : 'active'
const [showLoadDialog, setShowLoadDialog] = useState(false)
const [showPromoteDialog, setShowPromoteDialog] = useState(false)
const [showExpandedPreview, setShowExpandedPreview] = useState(false)
@@ -64,16 +67,9 @@ export function GeneralDeploy({
const revertMutation = useRevertToVersion()
useEffect(() => {
if (selectedVersion !== null) {
setPreviewMode('selected')
} else {
setPreviewMode('active')
}
}, [selectedVersion])
const handleSelectVersion = useCallback((version: number | null) => {
setSelectedVersion(version)
setShowActiveDespiteSelection(false)
}, [])
const handleLoadDeployment = useCallback((version: number) => {
@@ -164,7 +160,9 @@ export function GeneralDeploy({
>
<ButtonGroup
value={previewMode}
onValueChange={(val) => setPreviewMode(val as PreviewMode)}
onValueChange={(val) =>
setShowActiveDespiteSelection((val as PreviewMode) === 'active')
}
>
<ButtonGroupItem value='active'>Live</ButtonGroupItem>
<ButtonGroupItem value='selected' className='truncate'>

View File

@@ -227,12 +227,39 @@ export function DeployModal({
getApiKeyLabel,
])
const selectedStreamingOutputsRef = useRef(selectedStreamingOutputs)
selectedStreamingOutputsRef.current = selectedStreamingOutputs
useEffect(() => {
if (open && workflowId) {
setActiveTab('general')
setDeployError(null)
setDeployWarnings([])
setChatSuccess(false)
const currentOutputs = selectedStreamingOutputsRef.current
if (currentOutputs.length > 0) {
const blocks = Object.values(useWorkflowStore.getState().blocks)
const validOutputs = currentOutputs.filter((outputId) => {
if (startsWithUuid(outputId)) {
const underscoreIndex = outputId.indexOf('_')
if (underscoreIndex === -1) return false
const blockId = outputId.substring(0, underscoreIndex)
return blocks.some((b) => b.id === blockId)
}
const parts = outputId.split('.')
if (parts.length >= 2) {
const blockName = parts[0]
return blocks.some(
(b) => b.name?.toLowerCase().replace(/\s+/g, '') === blockName.toLowerCase()
)
}
return true
})
if (validOutputs.length !== currentOutputs.length) {
setSelectedStreamingOutputs(validOutputs)
}
}
}
return () => {
if (chatSuccessTimeoutRef.current) {
@@ -241,38 +268,6 @@ export function DeployModal({
}
}, [open, workflowId])
useEffect(() => {
if (!open || selectedStreamingOutputs.length === 0) return
const blocks = Object.values(useWorkflowStore.getState().blocks)
const validOutputs = selectedStreamingOutputs.filter((outputId) => {
if (startsWithUuid(outputId)) {
const underscoreIndex = outputId.indexOf('_')
if (underscoreIndex === -1) return false
const blockId = outputId.substring(0, underscoreIndex)
const block = blocks.find((b) => b.id === blockId)
return !!block
}
const parts = outputId.split('.')
if (parts.length >= 2) {
const blockName = parts[0]
const block = blocks.find(
(b) => b.name?.toLowerCase().replace(/\s+/g, '') === blockName.toLowerCase()
)
return !!block
}
return true
})
if (validOutputs.length !== selectedStreamingOutputs.length) {
setSelectedStreamingOutputs(validOutputs)
}
}, [open, selectedStreamingOutputs, setSelectedStreamingOutputs])
useEffect(() => {
const handleOpenDeployModal = (event: Event) => {
const customEvent = event as CustomEvent<{ tab?: TabView }>

View File

@@ -120,7 +120,6 @@ export const ComboBox = memo(function ComboBox({
)
// State management
const [storeInitialized, setStoreInitialized] = useState(false)
const [fetchedOptions, setFetchedOptions] = useState<Array<{ label: string; id: string }>>([])
const [isLoadingOptions, setIsLoadingOptions] = useState(false)
const [fetchError, setFetchError] = useState<string | null>(null)
@@ -280,27 +279,22 @@ export const ComboBox = memo(function ComboBox({
}, [value, evaluatedOptions])
const [inputValue, setInputValue] = useState(displayValue)
useEffect(() => {
const [prevDisplayValue, setPrevDisplayValue] = useState(displayValue)
if (displayValue !== prevDisplayValue) {
setPrevDisplayValue(displayValue)
setInputValue(displayValue)
}, [displayValue])
}
// Mark store as initialized on first render
useEffect(() => {
setStoreInitialized(true)
}, [])
// Set default value once store is initialized and permissions are loaded
// Set default value once permissions are loaded
useEffect(() => {
if (isPermissionLoading) return
if (!storeInitialized) return
if (defaultOptionValue === undefined) return
// Only set default when no value exists (initial block add)
if (value === null || value === undefined) {
setStoreValue(defaultOptionValue)
}
}, [storeInitialized, value, defaultOptionValue, setStoreValue, isPermissionLoading])
}, [value, defaultOptionValue, setStoreValue, isPermissionLoading])
// Clear fetched options and hydrated option when dependencies change
useEffect(() => {

View File

@@ -124,7 +124,6 @@ export const Dropdown = memo(function Dropdown({
isEqual
)
const [storeInitialized, setStoreInitialized] = useState(false)
const [fetchedOptions, setFetchedOptions] = useState<Array<{ label: string; id: string }>>([])
const [isLoadingOptions, setIsLoadingOptions] = useState(false)
const [fetchError, setFetchError] = useState<string | null>(null)
@@ -242,17 +241,13 @@ export const Dropdown = memo(function Dropdown({
}, [defaultValue, comboboxOptions, multiSelect])
useEffect(() => {
setStoreInitialized(true)
}, [])
useEffect(() => {
if (multiSelect || !storeInitialized || defaultOptionValue === undefined) {
if (multiSelect || defaultOptionValue === undefined) {
return
}
if (storeValue === null || storeValue === undefined || storeValue === '') {
setStoreValue(defaultOptionValue)
}
}, [storeInitialized, storeValue, defaultOptionValue, setStoreValue, multiSelect])
}, [storeValue, defaultOptionValue, setStoreValue, multiSelect])
/**
* Normalizes variable references in JSON strings by wrapping them in quotes

View File

@@ -122,11 +122,9 @@ export function LongInput({
isStreaming: wandHook.isStreaming,
})
useEffect(() => {
persistSubBlockValueRef.current = (value: string) => {
setSubBlockValue(value)
}
}, [setSubBlockValue])
persistSubBlockValueRef.current = (value: string) => {
setSubBlockValue(value)
}
// Check if wand is actually enabled
const isWandEnabled = config.wandConfig?.enabled ?? false
@@ -193,12 +191,12 @@ export function LongInput({
// Sync local content with base value when not streaming
useEffect(() => {
if (!wandHook.isStreaming) {
const baseValueString = baseValue?.toString() ?? ''
if (baseValueString !== localContent) {
setLocalContent(baseValueString)
}
setLocalContent((prev) => {
const baseValueString = baseValue?.toString() ?? ''
return baseValueString !== prev ? baseValueString : prev
})
}
}, [baseValue, wandHook.isStreaming]) // Removed localContent to prevent infinite loop
}, [baseValue, wandHook.isStreaming])
// Update height when rows prop changes
useLayoutEffect(() => {

Some files were not shown because too many files have changed in this diff Show More