Compare commits

..

78 Commits

Author SHA1 Message Date
Vikhyath Mondreti
33aa581351 address comments 2026-04-03 15:42:45 -07:00
Vikhyath Mondreti
bc41ea02b9 improvement(execution): multiple response blocks 2026-04-03 14:44:31 -07:00
Waleed
b0c0ee29a8 feat(email): send onboarding followup email 3 days after signup (#3906)
* feat(email): send onboarding followup email 3 days after signup

* fix(email): add trigger guard, idempotency key, and shared task ID constant

* fix(email): increase onboarding followup delay from 3 to 5 days
2026-04-02 18:08:14 -07:00
Waleed
20c05644ab fix(enterprise): smooth audit log list animation (#3905) 2026-04-02 16:03:03 -07:00
Waleed
f9d73db65c feat(rootly): expand Rootly integration from 14 to 27 tools (#3902)
* feat(rootly): expand Rootly integration from 14 to 27 tools

Add 13 new tools: delete_incident, get_alert, update_alert,
acknowledge_alert, resolve_alert, create_action_item, list_action_items,
list_users, list_on_calls, list_schedules, list_escalation_policies,
list_causes, list_playbooks. Includes tool files, types, registry,
block definition with subBlocks/conditions/params, and docs.

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

* fix(rootly): handle 204 No Content response for delete_incident

DELETE /v1/incidents/{id} returns 204 with empty body. Avoid calling
response.json() on success — return success/message instead.

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

* fix(rootly): remove non-TSDoc comments, add empty body to acknowledge_alert

Remove all inline section comments from block definition per CLAUDE.md
guidelines. Add explicit empty JSON:API body to acknowledge_alert POST
to prevent potential 400 from servers expecting a body with Content-Type.

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

* fix(rootly): send empty body on resolve_alert, guard assignedToUserId parse

resolve_alert now sends { data: {} } instead of undefined when no
optional params are provided, matching the acknowledge_alert fix.
create_action_item now validates assignedToUserId is numeric before
parseInt to avoid silent NaN coercion.

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

* fix(rootly): extract on-call relationships from JSON:API relationships/included

On-call user, schedule, and escalation policy are exposed as JSON:API
relationships, not flat attributes. Now extracts IDs from
item.relationships and looks up names from the included array.
Adds ?include=user,schedule,escalation_policy to the request URL.

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

* fix(rootly): remove last non-TSDoc comment from block definition

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

* docs

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 13:40:45 -07:00
Waleed
e2e53aba76 feat(agentmail): add AgentMail integration with 21 tools (#3901)
* feat(agentmail): add AgentMail integration with 21 tools

* fix(agentmail): clear stale to field when switching to reply_message operation

* fix(agentmail): guard messageId and label remappings with operation checks

* fix(agentmail): clean up subBlock titles

* fix(agentmail): guard replyTo and thread label remappings with operation checks

* fix(agentmail): guard inboxIdParam remapping with operation check

* fix(agentmail): guard permanent, replyAll, and draftInReplyTo with operation checks
2026-04-02 13:40:23 -07:00
Waleed
727bb1cadb fix(bullmq): restore CONCURRENCY_CONTROL_ENABLED flag guard (#3903) 2026-04-02 13:07:50 -07:00
Waleed
e2e29cefd7 fix(blog): use landing theme variables in MDX components (#3900) 2026-04-02 12:34:38 -07:00
Waleed
45f053a383 feat(rootly): add Rootly incident management integration with 14 tools (#3899)
* feat(rootly): add Rootly incident management integration with 14 tools

* fix(rootly): address PR review feedback - PATCH method, totalCount, environmentIds

- Changed update_incident HTTP method from PUT to PATCH per Rootly API spec
- Fixed totalCount in all 9 list tools to use data.meta?.total_count from API response
- Added missing updateEnvironmentIds subBlock and params mapping for update_incident

* fix(rootly): add id to PATCH body and unchanged option to update status dropdown

- Include incident id in JSON:API PATCH body per spec requirement
- Add 'Unchanged' empty option to updateStatus dropdown to avoid accidental overwrites

* icon update

* improvement(rootly): complete block-tool alignment and fix validation gaps

- Add missing get_incident output fields (private, shortUrl, closedAt)
- Add missing block subBlocks: createPrivate, alertStatus, alertExternalId, listAlertsServices
- Add pageNumber subBlocks for all 9 list operations
- Add teams/environments filter subBlocks for list_incidents and list_alerts
- Add environmentIds subBlock for create_alert
- Add empty default options to all optional dropdowns (createStatus, createKind, listIncidentsSort, eventVisibility)
- Wire all new subBlocks in tools.config.params and inputs
- Regenerate docs

* fix(rootly): align tools with OpenAPI spec

- list_incident_types: use filter[name] instead of unsupported filter[search]
- list_severities: add missing search param (filter[search])
- create_incident: title is optional per API (auto-generated if null)
- update_incident: add kind, private, labels, incidentTypeIds,
  functionalityIds, cancellationMessage params
- create/update/list incidents: add scheduled, in_progress, completed
  status values
- create_alert: fix status description (only open/triggered on create)
- add_incident_event: add updatedAt to response
- block: add matching subBlocks and params for all new tool fields

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

* fix(rootly): final validation fixes from OpenAPI spec audit

- update_incident: change PATCH to PUT per OpenAPI spec
- index.ts: add types re-export
- types.ts: fix id fields to string | null (matches ?? null runtime)
- block: add value initializers to 4 dropdowns missing them
- registry: fix alphabetical order (incident_types before incidents)

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

* reorg

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 11:40:40 -07:00
Waleed
225d5d551a improvement(models): update default to claude-sonnet-4-6 and reorganize OpenAI models (#3898)
* improvement(models): update default to claude-sonnet-4-6 and reorganize OpenAI models

* fix(tests): update stale claude-sonnet-4-5 references to claude-sonnet-4-6

* fix(combobox): rename misleading claudeSonnet45 variable to defaultModelOption
2026-04-02 10:51:27 -07:00
Waleed
a78f3f9c2e fix(credential): fix service_account migration to avoid unsafe enum usage in same transaction (#3897) 2026-04-02 10:16:08 -07:00
Waleed
080a0a6123 feat(rippling): expand Rippling integration from to 86 tools, landing updates (#3886)
* feat(rippling): expand Rippling integration from 16 to 86 tools

* fix(rippling): add required constraints on name and data subBlocks for create operations

* fix(rippling): add subblock ID migrations for removed legacy fields

* fix(docs): add MANUAL-CONTENT markers to tailscale docs and regenerate

* fix(rippling): add missing response fields to tool transforms

Add fields found missing by validation agents:
- list_companies: physical_address
- list/get_supergroups: sub_group_type, read_only, parent, mutually_exclusive_key, cumulatively_exhaustive_default, include_terminated
- list/get/create/update_custom_object: native_category_id, managed_package_install_id, owner_id
- list/get/create/update_custom_app: icon, pages
- list/get/create/update_custom_object_field: managed_package_install_id

* fix(rippling): add missing block outputs and required data conditions

- Add 17 missing collection output keys (titles, workLocations, supergroups, etc.)
- Add delete/bulk/report output keys (deleted, results, report_id, etc.)
- Mark data subBlock required for create_business_partner, create_custom_app,
  and create_custom_object_field (all have required params via data JSON spread)
- Add optional: true to get_current_user work_email and company_id outputs

* fix(rippling): add missing supergroup fields and fix validation issues

- Add 5 missing supergroup fields (allow_non_employees, can_override_role_states, priority, is_invisible, ignore_prov_group_matching) to types, list, and get tools
- Fix ok fallback from true to false in supergroup inclusion/exclusion member update tools
- Fix truthy check to null check for description param in create_custom_object_field

* fix(rippling): add missing custom page fields and structured custom setting responses

- Add 5 missing CustomPage fields (components, actions, canvas_actions, variables, media) to types and all page tools
- Replace opaque data blob with structured field mapping in create/update custom setting transforms
- Fix secret_value type cast consistency in list_custom_settings

* fix(rippling): add missing response fields, fix truthy checks, and improve UX

- Add 9 missing Worker fields (location, gender, date_of_birth, race, ethnicity, citizenship, termination_details, custom_fields, country_fields)
- Add 5 missing User fields (name, emails, phone_numbers, addresses, photos)
- Add worker expandable field to GroupMember types and all 3 member list tools
- Add 5 optional params to trigger_report_run (includeObjectIds, includeTotalRows, formatDateFields, formatCurrencyFields, outputType)
- Fix truthy checks to null checks in create_department, create/update_work_location
- Fix customObjectId subBlock label to say "API Name" instead of "ID"

* update docs

* fix(rippling): fix truthy checks, add missing fields, and regenerate docs

- Replace all `if (params.x)` with `if (params.x != null)` across 30+ tool files to prevent empty string/false/zero suppression
- Add expandable `parent` and `department_hierarchy` fields to department tools
- Add expandable `parent` field to team tools
- Add `company` expandable field to get_current_user
- Add `addressType` param to create/update work location tools
- Fix `secret_value` output type from 'json' to 'string' in list_custom_settings
- Regenerate docs for all 86 tools from current definitions

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

* fix(rippling): add all remaining spec fields and regenerate docs

- Add 6 advanced params to create_custom_object_field: required, rqlDefinition,
  formulaAttrMetas, section, derivedFieldFormula, derivedAggregatedField
- Add 6 advanced params to update_custom_object_field: required, rqlDefinition,
  formulaAttrMetas, section, derivedFieldFormula, nameFieldDetails
- Add 4 record output fields to all custom object record tools: created_by,
  last_modified_by, owner_role, system_updated_at
- Add cursor param to get_current_user
- Add __meta response field to get_report_run
- Regenerate docs for all 86 tools

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

* fix(rippling): align all tools with OpenAPI spec

- Add __meta to 14 GET-by-ID tools (MetaResponse pattern)
- Fix supergroup tools: add filter to list_supergroups, remove invalid
  cursor from 4 list endpoints, revert update members to PATCH with
  Operations body
- Fix query_custom_object_records: use query/limit/cursor body params,
  return cursor instead of nextLink
- Fix bulk_create: use rows_to_write per spec
- Fix create/update record body wrappers with externalId support
- Update types.ts param interfaces and block config mappings
- Add limit param mapping with Number() conversion in block config
- Regenerate docs

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

* fix(rippling): address PR review comments — add dedicated subBlocks, fix data duplication, expand externalId condition

- Add dedicated apiName, businessPartnerGroupId, workerId, dataType subBlocks so required params are no longer hidden behind opaque data JSON
- Narrow `data: item` in custom object record tools to only include dynamic fields, avoiding duplication of enumerated fields
- Expand externalId subBlock condition to include create/update custom object record operations

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

* fix(rippling): remove data JSON required for ops with dedicated subBlocks

create_business_partner, create_custom_app, and create_custom_object_field
now have dedicated subBlocks for their required params, so the data JSON
field is supplementary (not required) for those operations.

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

* fix(rippling): use rest-destructuring for all custom object record data output

The spec uses additionalProperties for custom fields at the top level,
not a nested `data` sub-object. Use the same rest-destructuring pattern
across all 6 custom object record tools so `data` only contains dynamic
fields, not duplicates of enumerated standard fields.

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

* fix(rippling): make update_custom_object_record data param optional in type

Matches the tool's `required: false` — users may update only external_id
without changing data.

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

* fix(rippling): add dedicated streetAddress subBlock for create_work_location

streetAddress is required by the tool but had no dedicated subBlock —
users had to include it in the data JSON. Now has its own required
subBlock matching the pattern used by all other required params.

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

* fix(rippling): add allOrNothing subBlock for bulk operations

The bulk create/update/delete tools accept an optional allOrNothing
boolean param, but it had no subBlock and no way to be passed through
the block UI. Added as an advanced-mode dropdown with boolean coercion.

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

* fix(rippling): derive spreadOps from DATA_OPS to prevent divergence

Replace the hardcoded spreadOps array with a derivation from the
file-level DATA_OPS constant minus non-spread operations. This ensures
new create/update operations added to DATA_OPS automatically get
spread behavior without needing a second manual update.

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

* updated

* fix(rippling): replace generic JSON outputs with specific fields per API spec

- Extract file_url, expires_at, output_type from report run result blob
- Rename bulk create/update outputs to createdRecords/updatedRecords
- Fix list_custom_settings output key mismatch (settings → customSettings)
- Make data optional for update_custom_object_record in block
- Update block outputs to match new tool output fields

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

* fix landing

* restore FF

* fix(rippling): add wandConfig, clean titles, and migrate legacy operation values

- Remove "(JSON)" suffix from all subBlock titles
- Add wandConfig with AI prompts for filter, expand, orderBy, query, data, records, and dataType fields
- Add OPERATION_VALUE_MIGRATIONS to migrate old operation values (list_employees → list_workers, etc.) preventing runtime errors on saved workflows

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

* fix(rippling): fix grammar typos and revert unnecessary migration

- Fix "a object" → "an object" in update/delete object category descriptions
- Revert OPERATION_VALUE_MIGRATIONS (unnecessary for low-usage integration)

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

* feat(landing): add interactive workspace preview tabs

Adds Tables, Files, Knowledge Base, Logs, and Scheduled Tasks preview
components to the landing hero, with sidebar nav items that switch to each view.

* test updates

* refactor(landing): clean up code quality issues in preview components

- Replace widthMultiplier with explicit width on PreviewColumn
- Replace key={i} with key={Icon.name} in connectorIcons
- Scope --c-active CSS variable to sidebar container, eliminating hardcoded #363636 duplication
- Replace '-  -  -' fallback with em dash
- Type onSelectNav as (id: SidebarView) removing the unsafe cast

* fix(landing): use stable index key in connectorIcons to avoid minification breakage

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 01:30:43 -07:00
Theodore Li
fc6fe193fa fix(credential) fix credential migration (#3896)
* fix(credential) fix credential migration

* Fix lint

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-04-02 04:29:01 -04:00
Theodore Li
bbc704fe05 feat(credentials) Add google service account support (#3828)
* feat(auth): allow google service account

* Add gmail support for google services

* Refresh creds on typing in impersonated email

* Switch to adding subblock impersonateUserEmail conditionally

* Directly pass subblock for impersonateUserEmail

* Fix lint

* Update documentation for google service accounts

* Fix lint

* Address comments

* Remove hardcoded scopes, remove orphaned migration script

* Simplify subblocks for google service account

* Fix lint

* Fix build error

* Fix documentation scopes listed for google service accounts

* Fix issue with credential selector, remove bigquery and ad support

* create credentialCondition

* Shift conditional render out of subblock

* Simplify sublock values

* Fix security message

* Handle tool service accounts

* Address bugbot

* Fix lint

* Fix manual credential input not showing impersonate

* Fix tests

* Allow watching param id and subblock ids

* Fix bad test

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-04-02 03:08:13 -04:00
Theodore Li
c016537564 fix(blog): Fix blog not loading (#3895)
* Fix blog not loading

* Use emcn icon

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-04-02 00:53:31 -04:00
Waleed
4c94f3cf78 improvement(providers): audit and update all provider model definitions (#3893)
* improvement(providers): audit and update all provider model definitions

* fix(providers): add maxOutputTokens to azure/o3 and azure/o4-mini

* fix(providers): move maxOutputTokens inside capabilities for azure models
2026-04-01 19:32:16 -07:00
Vikhyath Mondreti
27a11a269d improvement(workflow): seed start block on server side (#3890)
* improvement(workflow): seed start block on server side

* add creating state machine for optimistic switch

* fix worksapce switch

* address comments

* address error handling at correct level
2026-04-01 19:04:34 -07:00
Waleed
2c174ca4f6 feat(landing): added models pages (#3888)
* feat(landing): added models pages

* fix(models): address PR review feedback

Correct model structured-data price bounds, remove dead code in the models catalog helpers, and harden OG font loading with graceful fallbacks.

Made-with: Cursor

* relative imports, build fix

* lint

* fix(models): remove dead og-utils exports, fix formatTokenCount null guard
2026-04-01 18:23:35 -07:00
Waleed
ac831b85b2 chore(bun): update bunfig.toml (#3889)
* chore(bun): update bunfig.toml

* outdated bun lock

* chore(deps): downgrade @aws-sdk/client-secrets-manager to 3.940.0
2026-04-01 17:21:00 -07:00
Waleed
8527ae5d3b feat(providers): server-side credential hiding for Azure and Bedrock (#3884)
* fix: allow Bedrock provider to use AWS SDK default credential chain

Remove hard requirement for explicit AWS credentials in Bedrock provider.
When access key and secret key are not provided, the AWS SDK automatically
falls back to its default credential chain (env vars, instance profile,
ECS task role, EKS IRSA, SSO).

Closes #3694

Signed-off-by: majiayu000 <1835304752@qq.com>

* fix: add partial credential guard for Bedrock provider

Reject configurations where only one of bedrockAccessKeyId or
bedrockSecretKey is provided, preventing silent fallback to the
default credential chain with a potentially different identity.

Add tests covering all credential configuration scenarios.

Signed-off-by: majiayu000 <1835304752@qq.com>

* fix: clean up bedrock test lint and dead code

Remove unused config parameter and dead _lastConfig assignment
from mock factory. Break long mockReturnValue chain to satisfy
biome line-length rule.

Signed-off-by: majiayu000 <1835304752@qq.com>

* fix: address greptile review feedback on PR #3708

Use BedrockRuntimeClientConfig from SDK instead of inline type.
Add default return value for prepareToolsWithUsageControl mock.

Signed-off-by: majiayu000 <1835304752@qq.com>

* feat(providers): server-side credential hiding for Azure and Bedrock

* fix(providers): revert Bedrock credential fields to required with original placeholders

* fix(blocks): add hideWhenEnvSet to getProviderCredentialSubBlocks for Azure and Bedrock

* fix(agent): use getProviderCredentialSubBlocks() instead of duplicating credential subblocks

* fix(blocks): consolidate Vertex credential into shared factory with basic/advanced mode

* fix(types): resolve pre-existing TypeScript errors across auth, secrets, and copilot

* lint

* improvement(blocks): make Vertex AI project ID a password field

* fix(blocks): preserve vertexCredential subblock ID for backwards compatibility

* fix(blocks): follow canonicalParamId pattern correctly for vertex credential subblocks

* fix(blocks): keep vertexCredential subblock ID stable to preserve saved workflow state

* fix(blocks): add canonicalParamId to vertexCredential basic subblock to complete the swap pair

* fix types

* more types

---------

Signed-off-by: majiayu000 <1835304752@qq.com>
Co-authored-by: majiayu000 <1835304752@qq.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-04-01 16:27:54 -07:00
Waleed
076c835ba2 improvement(credentials): consolidate OAuth modals and auto-fill credential name (#3887)
* improvement(credentials): consolidate OAuth modals and auto-fill credential name

* fix(credentials): context-aware subtitle for KB vs workflow
2026-04-01 15:48:49 -07:00
Vikhyath Mondreti
df6ceb61a4 fix(envvar): remove dead env var 2026-04-01 14:01:13 -07:00
Vikhyath Mondreti
2ede12aa0e fix(cost): worker crash incremenental case (#3885) 2026-04-01 11:42:19 -07:00
Waleed
42fb434354 fix(encryption): specify authTagLength on all AES-GCM cipher/decipher calls (#3883)
* fix: specify authTagLength in AES-GCM decipheriv calls

Fixes missing authTagLength parameter in createDecipheriv calls using
AES-256-GCM mode. Without explicit tag length specification, the
application may be tricked into accepting shorter authentication tags,
potentially allowing ciphertext spoofing.

CWE-310: Cryptographic Issues (gcm-no-tag-length)

* fix: specify authTagLength on createCipheriv calls for AES-GCM consistency

Complements #3881 by adding explicit authTagLength: 16 to the encrypt
side as well, ensuring both cipher and decipher specify the tag length.

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

* refactor: clean up crypto modules

- Fix error: any → error: unknown with proper type guard in encryption.ts
- Eliminate duplicate iv.toString('hex') calls in both encrypt functions
- Remove redundant string split in decryptApiKey (was splitting twice)

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

* new turborepo version

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Lakee Sivaraya <71339072+lakeesiv@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
Co-authored-by: NLmejiro <kuroda.k1021@gmail.com>
2026-04-01 10:46:58 -07:00
Waleed
dcebe3ae97 improvement(triggers): add tags to all trigger.dev task invocations (#3878)
* improvement(triggers): add tags to all trigger.dev task invocations

* fix(triggers): prefix unused type param in buildTags

* fix(triggers): remove unused type param from buildTags
2026-03-31 19:52:33 -07:00
Waleed
e39c534ee3 feat(providers): add Fireworks AI provider integration (#3873)
* feat(providers): add Fireworks AI provider integration

* fix(providers): remove unused logger and dead modelInfo from fireworks

* lint

* feat(providers): add Fireworks BYOK support and official icon

* fix(providers): add workspace membership check and remove shared fetch cache for fireworks models
2026-03-31 19:22:04 -07:00
Vikhyath Mondreti
b95a0491a0 fix(kb): chunking config persistence (#3877)
* fix(kb): persist chunking config correctly

* fix kb config as sot

* remove dead code

* fix doc req bodies

* add defaults for async for legacy docs
2026-03-31 19:16:23 -07:00
Waleed
a79c8a75ce fix(chat): align floating chat send button colors with home/mothership chat (#3876) 2026-03-31 18:11:02 -07:00
Vikhyath Mondreti
282ec8c58c fix(reorder): drag and drop hook (#3874)
* fix(reorder): drag and drop hook

* fix custom tool dropdown color

* fix mcp server url change propagation
2026-03-31 17:33:08 -07:00
Waleed
e45fbe0184 improvement(attio): validate integration, fix event bug, add missing tool and triggers (#3872)
* improvement(attio): validate integration, fix event bug, add missing tool and triggers

* fix(attio): wire new trigger extractors into dispatcher, trim targetUrl

Add extractAttioListData and extractAttioWorkspaceMemberData dispatch
branches in utils.server.ts so the four new triggers return correct
outputs instead of falling through to generic extraction.

Also add missing .trim() on targetUrl in update_webhook.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 16:44:49 -07:00
Waleed
512558dcb3 feat(launchdarkly): add LaunchDarkly integration for feature flag management (#3870)
* feat(launchdarkly): add LaunchDarkly integration for feature flag management

* fix(launchdarkly): guard empty instructions array, trim apiKey in auth header

* lint
2026-03-31 16:40:06 -07:00
Waleed
35411e465e feat(models): add gpt-5.4-mini and gpt-5.4-nano (#3871) 2026-03-31 16:32:24 -07:00
Waleed
72e28baa07 feat(extend): add Extend AI document processing integration (#3869)
* feat(extend): add Extend AI document processing integration

* fix(extend): cast json response to fix type error

* fix(extend): correct API request body structure per Extend docs

* fix(extend): address PR review comments

* fix(extend): sync integrations.json bgColor to #000000

* lint
2026-03-31 16:26:34 -07:00
Waleed
d99dd86bf2 feat(tailscale): add Tailscale integration with 20 API operations (#3868)
* feat(tailscale): add Tailscale integration with 20 API operations

* fix(tailscale): fix transformResponse signatures and block output types

* fix(tailscale): safe response.json() pattern, trim apiKey, guard expirySeconds
2026-03-31 16:26:17 -07:00
Waleed
7898e5d75f improvement(workflows): replace Zustand workflow sync with React Query as single source of truth (#3860)
* improvement(workflows): replace Zustand workflow sync with React Query as single source of truth

* fix(workflows): address PR review feedback — sandbox execution, hydration deadlock, test mock, copy casing

* lint

* improvement(workflows): adopt skipToken over enabled+as-string for type-safe conditional queries

* improvement(workflows): remove dead complexity, fix mutation edge cases

- Throw on state PUT failure in useCreateWorkflow instead of swallowing
- Use Map for O(1) lookups in duplicate/export loops (3 hooks)
- Broaden invalidation scope in update/delete mutations to lists()
- Switch workflow-block to useWorkflowMap for direct ID lookup
- Consolidate use-workflow-operations to single useWorkflowMap hook
- Remove workspace transition guard (sync body, unreachable timeout)
- Make switchToWorkspace synchronous (remove async/try-catch/finally)

* fix(workflows): resolve cold-start deadlock on direct URL navigation

loadWorkflowState used hydration.workspaceId (null on cold start) to
look up the RQ cache, causing "Workflow not found" even when the
workflow exists in the DB. Now falls back to getWorkspaceIdFromUrl()
and skips the cache guard when the cache is empty (letting the API
fetch proceed).

Also removes the redundant isRegistryReady guard in workflow.tsx that
blocked setActiveWorkflow when hydration.workspaceId was null.

* fix(ui): prevent flash of empty state while workflows query is pending

Dashboard and EmbeddedWorkflow checked workflow list length before
the RQ query resolved, briefly showing "No workflows" or "Workflow
not found" on initial load. Now gates on isPending first.

* fix(workflows): address PR review — await description update, revert state PUT throw

- api-info-modal: use mutateAsync for description update so errors
  are caught by the surrounding try/catch instead of silently swallowed
- useCreateWorkflow: revert state PUT to log-only — the workflow is
  already created in the DB, throwing rolls back the optimistic entry
  and makes it appear the creation failed when it actually succeeded

* move folders over to react query native, restructure passage of data

* pass signal correctly

* fix types

* fix workspace id

* address comment

* soft deletion accuring

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-03-31 15:49:16 -07:00
Waleed
df62502903 feat(infra): add dev environment support (#3867)
* feat(infra): add dev environment support

* fix(ci): push :dev ECR tag when building from dev branch

* fix(feature-flags): simplify isHosted subdomain check

* fix(ci,feature-flags): guard URL parse, fix dev AWS creds in images.yml
2026-03-31 15:36:57 -07:00
Waleed
1a2aa6949e feat(secrets-manager): add AWS Secrets Manager integration (#3866)
* feat(secrets-manager): add AWS Secrets Manager integration

* fix(secrets-manager): address PR review feedback

- Conditional delete message based on forceDelete flag
- Add binary secret detection in getSecretValue

* fix(secrets-manager): handle boolean forceDelete and validate numeric inputs

- Accept both string 'true' and boolean true for forceDelete
- Guard parseInt results with isNaN check for maxResults and recoveryWindowInDays
2026-03-31 15:26:03 -07:00
Waleed
4544fd4519 improvement(ui): fix nav loading flash, skeleton mismatches, and React anti-patterns across resource pages (#3864)
* improvement(ui): fix nav loading flash, skeleton mismatches, and React anti-patterns across resource pages

- Convert knowledge, files, tables, scheduled-tasks, and home page.tsx files from async server components to simple client re-exports, eliminating the loading.tsx flash on every navigation
- Add client-side permission redirects (usePermissionConfig) to knowledge, files, and tables components to replace server-side checks
- Fix knowledge loading.tsx skeleton column count (6→7) and tables loading.tsx (remove phantom checkbox column)
- Fix connector document live updates: use isConnectorSyncingOrPending instead of status === 'syncing' so polling activates immediately after connector creation
- Remove dead chunk-switch useEffect in ChunkEditor (redundant with key prop remount)
- Replace useState+useEffect debounce with useDebounce hook in document.tsx
- Replace useRef+useEffect URL init with lazy useState initializers in document.tsx and logs.tsx
- Make handleToggleEnabled optimistic in document.tsx (cache first, onError rollback)
- Replace mutate+new Promise wrapper with mutateAsync+try/catch in base.tsx
- Fix schedule-modal.tsx: replace 15-setter useEffect with useState lazy initializers + key prop remount; wrap parseCronToScheduleType in useMemo
- Fix logs search: eliminate mount-only useEffect with eslint-disable by passing initialQuery to useSearchState; parse query once via shared initialParsed state
- Add useWorkspaceFileRecord hook to workspace-files.ts; refactor FileViewer to self-fetch
- Fix value: any → value: string in useTagSelection and collaborativeSetTagSelection
- Fix knowledge-tag-filters.tsx: pass '' instead of null when filters are cleared (type safety)

* fix(kb): use active scope in useWorkspaceFileRecord to share cache with useWorkspaceFiles

* fix(logs,kb,tasks): lazy-init useRef for URL param, add cold-path docs to useWorkspaceFileRecord, document key remount requirement in ScheduleModal

* fix(files): redirect to files list when file record not found in viewer

* revert(files): remove useEffect redirect from file-viewer, keep simple null return

* fix(scheduled-tasks): correct useMemo dep from schedule?.cronExpression to schedule
2026-03-31 11:56:58 -07:00
Geonwoo Kim
019630bdc8 fix(vllm): pass env.VLLM_API_KEY to chat requests (#3865) 2026-03-31 10:33:44 -07:00
Vikhyath Mondreti
90f592797a fix(file): use file-upload subblock (#3862)
* fix(file): use file-upload subblock

* fix preview + logs url for notifs

* fix color for profound

* remove canonical param from desc
2026-03-30 21:29:01 -07:00
Waleed
d091441e39 feat(logs): add copy link and deep-link support for log entries (#3863)
* feat(logs): add copy link and deep link support for log entries

* fix(logs): move Link icon to emcn and handle clipboard rejections

* feat(notifications): use executionId deep-link for View Log URLs

Switch buildLogUrl from ?search= to ?executionId= so email and Slack
'View Log' buttons open the logs page with the specific execution
auto-selected and the details panel expanded.
2026-03-30 21:17:58 -07:00
Waleed
7d4dd26760 fix(knowledge): fix document processing stuck in processing state (#3857)
* fix(knowledge): fix document processing stuck in processing state

* fix(knowledge): use Promise.allSettled for document dispatch and fix Copilot OAuth context

- Change Promise.all to Promise.allSettled in processDocumentsWithQueue so
  one failed dispatch doesn't abort the entire batch
- Add writeOAuthReturnContext before showing LazyOAuthRequiredModal from
  Copilot tools so useOAuthReturnForWorkflow can handle the return
- Add consumeOAuthReturnContext on modal close to clean up stale context

* fix(knowledge): fix type error in useCredentialRefreshTriggers call

Pass empty string instead of undefined for connectorProviderId fallback
to match the hook's string parameter type.

* upgrade turbo

* fix(knowledge): fix type error in connectors-section useCredentialRefreshTriggers call

Same string narrowing fix as add-connector-modal — pass empty string
fallback for providerId.
2026-03-30 20:35:08 -07:00
Vikhyath Mondreti
0abeac77e1 improvement(platform): standardize perms, audit logging, lifecycle across admin, copilot, ui actions (#3858)
* improvement(platform): standardize perms, audit logging, lifecycle mgmt across admin, copilot, ui actions

* address comments

* improve error codes

* address bugbot comments

* fix test
2026-03-30 20:25:38 -07:00
Waleed
e9c94fa462 feat(logs): add copy link and deep link support for log entries (#3855)
* feat(logs): add copy link and deep link support for log entries

* fix(logs): fetch next page when deep linked log is beyond initial page

* fix(logs): move Link icon to emcn and handle clipboard rejections

* fix(logs): track isFetching reactively and drop empty-list early-return

- Remove  guard that prevented clearing the
  pending ref when filters return no results
- Use  directly in the condition and add it to
  the effect deps so the effect re-triggers after a background refetch

* fix(logs): guard deep-link ref clear until query has succeeded

Only clear pendingExecutionIdRef when the query status is 'success',
preventing premature clearing before the initial fetch completes.
On mount, the query is disabled (isInitialized.current starts false),
so hasNextPage is false but no data has loaded yet — the ref was being
cleared in the same effect pass that set it.

* fix(logs): guard fetchNextPage call until query has succeeded

Add logsQuery.status === 'success' to the fetchNextPage branch so it
mirrors the clear branch. On mount the query is disabled (isFetching is
false, status is pending), causing the effect to call fetchNextPage()
before the query is initialized — now both branches require success.
2026-03-30 18:42:27 -07:00
Waleed
72eea64bf6 improvement(tour): align product tour tooltip styling with emcn and fix spotlight overflow (#3854) 2026-03-30 16:59:53 -07:00
Waleed
27460f847c fix(atlassian): harden cloud ID resolution for Confluence and Jira (#3853) 2026-03-30 16:54:45 -07:00
Vikhyath Mondreti
c7643198dc fix(mothership): hang condition (#3852) 2026-03-30 16:47:54 -07:00
Waleed
e5aef6184a feat(profound): add Profound AI visibility and analytics integration (#3849)
* feat(profound): add Profound AI visibility and analytics integration

* fix(profound): fix import ordering and JSON formatting for CI lint

* fix(profound): gate metrics mapping on current operation to prevent stale overrides

* fix(profound): guard JSON.parse on filters, fix offset=0 falsy check, remove duplicate prompt_answers in FILTER_OPS

* lint

* fix(docs): fix import ordering and trailing newline for docs lint

* fix(scripts): sort generated imports to match Biome's organizeImports order

* fix(profound): use != null checks for limit param across all tools

* fix(profound): flatten block output type to 'json' to pass block validation test

* fix(profound): remove invalid 'required' field from block inputs (not part of ParamConfig)

* fix(profound): rename tool files from kebab-case to snake_case for docs generator compatibility

* lint

* fix(docs): let biome auto-fix import order, revert custom sort in generator

* fix(landing): fix import order in sim icon-mapping via biome

* fix(scripts): match Biome's exact import sort order in docs generator

* fix(generate-docs): produce Biome-compatible JSON output

The generator wrote multi-line arrays for short string arrays (like tags)
and omitted trailing newlines, causing Biome format check failures in CI.
Post-process integrations.json to collapse short arrays onto single lines
and add trailing newlines to both integrations.json and meta.json.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 16:30:06 -07:00
Waleed
4ae5b1b620 improvement(workflow): use DOM hit-testing for edge drop-on-block detection (#3851) 2026-03-30 16:20:30 -07:00
Waleed
5c334874eb fix(auth): use standard 'Unauthorized' error in hybrid auth responses (#3850) 2026-03-30 16:11:04 -07:00
Theodore Li
d3d58a9615 Feat/improved logging (#3833)
* feat(logs): add additional metadata for workflow execution logs

* Revert "Feat(logs) upgrade mothership chat messages to error (#3772)"

This reverts commit 9d1b9763c5.

* Fix lint, address greptile comments

* improvement(sidebar): expand sidebar by hovering and clicking the edge (#3830)

* improvement(sidebar): expand sidebar by hovering and clicking the edge

* improvement(sidebar): add keyboard shortcuts for new workflow/task, center search modal, fix edge ARIA

* improvement(sidebar): use Tooltip.Shortcut for inline shortcut display

* fix(sidebar): change new workflow shortcut from Mod+Shift+W to Mod+Shift+P to avoid browser close-window conflict

* fix(hotkeys): fall back to event.code for international keyboard layout compatibility

* fix(sidebar): guard add-workflow shortcut with canEdit and isCreatingWorkflow checks

* feat(ui): handle image paste (#3826)

* feat(ui): handle image paste

* Fix lint

* Fix type error

---------

Co-authored-by: Theodore Li <theo@sim.ai>

* feat(files): interactive markdown checkbox toggling in preview (#3829)

* feat(files): interactive markdown checkbox toggling in preview

* fix(files): handle ordered-list checkboxes and fix index drift

* lint

* fix(files): remove counter offset that prevented checkbox toggling

* fix(files): apply task-list styling to ordered lists too

* fix(files): render single pass when interactive to avoid index drift

* fix(files): move useMemo above conditional return to fix Rules of Hooks

* fix(files): pass content directly to preview when not streaming to avoid stale frame

* improvement(home): position @ mention popup at caret and fix icon consistency (#3831)

* improvement(home): position @ mention popup at caret and fix icon consistency

* fix(home): pin mirror div to document origin and guard button anchor

* chore(auth): restore hybrid.ts to staging

* improvement(ui): sidebar (#3832)

* Fix logger tests

* Add metadata to mothership logs

---------

Co-authored-by: Theodore Li <theo@sim.ai>
Co-authored-by: Waleed <walif6@gmail.com>
Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-30 19:02:17 -04:00
Waleed
1d59eca90a fix(analytics): use getBaseDomain for Profound host field (#3848)
request.url resolves to internal ALB hostname on ECS, not the public domain
2026-03-30 15:36:41 -07:00
Theodore Li
e1359b09d6 feat(block) add block write and append operations (#3665)
* Add file write and delete operations

* Add file block write operation

* Fix lint

* Allow loop-in-loop workflow edits

* Fix type error

* Remove file id input, output link correctly

* Add append tool

* fix lint

* Address feedback

* Handle writing to same file name gracefully

* Removed  mime type from append block

* Add lock for file append operation

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-30 18:08:51 -04:00
Waleed
35b3646330 fix(sidebar): cmd+click opens in new tab, shift+click for range select (#3846)
* fix(sidebar): cmd+click opens in new tab, shift+click for range select

* comment cleanup

* fix(sidebar): drop stale metaKey param from workflow and task selection hooks
2026-03-30 11:02:33 -07:00
Waleed
5c47ea58f8 chore(trigger): update @trigger.dev/sdk and @trigger.dev/build to 4.4.3 (#3843)
* chore(trigger): update @trigger.dev/sdk and @trigger.dev/build to 4.4.3

* fix(webhooks): execute non-polling webhooks inline when BullMQ is disabled
2026-03-30 09:27:15 -07:00
Vikhyath Mondreti
c4f4e6b48c fix(bullmq): disable temporarily (#3841) 2026-03-30 00:04:59 -07:00
Waleed
1728c370de improvement(landing): lighthouse performance and accessibility fixes (#3837)
* improvement(landing): lighthouse performance and accessibility fixes

* improvement(landing): extract FeatureToggleItem to deduplicate accessibility logic

* lint

* fix(landing): ensure explicit delay prop takes precedence over transition spread
2026-03-29 22:33:34 -07:00
Waleed
82e58a5082 fix(academy): hide academy pages until content is ready (#3839)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 22:18:13 -07:00
Waleed
336c065234 fix(viewer): image pan/zoom, sort fixes, sidebar dot fixes (#3836)
* feat(file-viewer): add pan and zoom to image preview

* fix(viewer): fix sort key mapping, disable load-more on sort, hide status dots when menu open

* fix(file-viewer): prevent scroll bleed and zoom button micro-pans

* fix(file-viewer): use exponential zoom formula to prevent zero/negative multiplier
2026-03-29 12:40:29 -07:00
Waleed
b3713642b2 feat(resources): add sort and filter to all resource list pages (#3834)
* improvement(tables): improve table filtering UX

- Replace popover filter with persistent inline panel below toolbar
- Add AND/OR toggle between filter rules (shown in Where label slot)
- Sync filter panel state from applied filter on open
- Show filter button active state when filter is applied or panel is open
- Use readable operator labels matching dropdown options
- Add Clear filters button (shown only when filter is active)
- Close filter panel when last rule is removed via X
- Fix empty gap rows appearing in filtered results by skipping position gap rendering when filter is active
- Add toggle mode to ResourceOptionsBar for inline panel pattern
- Memoize FilterRuleRow for perf, fix filterTags key collision, remove dead filterActiveCount prop

* fix(table-filter): use ref to stabilize handleRemove/handleApply callbacks

Reading rules via ref instead of closure eliminates rules from useCallback
dependency arrays, keeping callbacks stable across rule edits and preserving
the memo() benefit on FilterRuleRow.

* improvement(tables,kb): remove hacky patterns, fix KB filter popover width

- Remove non-TSDoc comment from table-filter (rulesRef pattern is self-evident)
- Simplify SearchSection: remove setState-during-render anti-pattern; controlled
  input binds directly to search.value/onChange (simpler and correct)
- Reduce KB filter popover from w-[320px] to w-[200px]; tag filter uses vertical
  layout so narrow width works; Status-only case is now appropriately compact

* feat(knowledge): add sort and filter to KB list page

Sort dropdown: name, documents, tokens, created, last updated — pre-sorted
externally before passing rows to Resource. Active sort highlights the Sort
button; clear resets to default (created desc).

Filter popover: filter by connector status (All / With connectors /
Without connectors). Active filter shown as a removable tag in the toolbar.

* feat(files): add sort and filter to files list page

* feat(scheduled-tasks): add sort and filter to scheduled tasks page

* fix(table-filter): use explicit close handler instead of toggle

* improvement(files,knowledge): replace manual debounce with useDebounce hook and use type guards for file filtering

* fix(resource): prevent popover from inheriting anchor min-width

* feat(tables): add sort to tables list page

* feat(knowledge): add content and owner filters to KB list

* feat(scheduled-tasks): add status and health filters

* feat(files): add size and uploaded-by filters to files list

* feat(tables): add row count, owner, and column type filters

* improvement(scheduled-tasks): use combobox filter panel matching logs UI style

* improvement(knowledge): use combobox filter panel matching logs UI style

* improvement(files): use combobox filter panel matching logs UI style

Replaces button-list filters with Combobox-based multi-select sections for file type, size, and uploaded-by filters, aligning the panel with the logs page filter UI.

* improvement(tables): use combobox filter panel matching logs UI style

* feat(settings): add sort to recently deleted page

Add a sort dropdown next to the search bar allowing users to sort by deletion date (default, newest first), name (A–Z), or type (A–Z).

* feat(logs): add sort to logs page

* improvement(knowledge): upgrade document list filter to combobox style

* fix(resources): fix missing imports, memoization, and stale refs across resource pages

* improvement(tables): remove column type filter

* fix(resources): fix filter/sort correctness issues from audit

* fix(chunks): add server-side sort to document chunks API

Chunk sort was previously done client-side on a single page of
server-paginated data, which only reordered the current page.
Now sort params (sortBy, sortOrder) flow through the full stack:
types → service → API route → query hook → useDocumentChunks → document.tsx.

* perf(resources): memoize filterContent JSX across all resource pages

Resource is wrapped in React.memo, so an unstable filterContent reference
on every parent re-render defeats the memo. Wrap filterContent in useMemo
with correct deps in all 6 pages (files, tables, scheduled-tasks, knowledge,
base, document).

* fix(resources): add missing sort options for all visible columns

Every column visible in a resource table should be sortable. Three pages
had visible columns with no sort support:
- files.tsx: add 'owner' sort (member name lookup)
- scheduled-tasks.tsx: add 'schedule' sort (localeCompare on description)
- knowledge.tsx: add 'connectors' (count) and 'owner' (member name) sorts

Also add 'members' to processedKBs deps in knowledge.tsx since owner
sort now reads member names inside the memo.

* whitelabeling updates, sidebar fixes, files bug

* increased type safety

* pr fixes
2026-03-28 23:31:54 -07:00
Waleed
b9b930bb63 feat(analytics): add Profound web traffic tracking (#3835)
* feat(analytics): add Profound web traffic tracking

* fix(analytics): address PR review — add endpoint check and document trade-offs

* chore(analytics): remove implementation comments

* fix(analytics): guard sendToProfound with try-catch and align check with isProfoundEnabled

* fix(analytics): strip sensitive query params and remove redundant guard

* chore(analytics): remove unnecessary query param filtering
2026-03-28 22:09:23 -07:00
Vikhyath Mondreti
f1ead2ed55 fix docker image build 2026-03-28 20:58:56 -07:00
Waleed
30377d775b improvement(ui): sidebar (#3832) 2026-03-28 15:24:07 -07:00
Waleed
d013132d0e improvement(home): position @ mention popup at caret and fix icon consistency (#3831)
* improvement(home): position @ mention popup at caret and fix icon consistency

* fix(home): pin mirror div to document origin and guard button anchor

* chore(auth): restore hybrid.ts to staging
2026-03-28 14:48:41 -07:00
Waleed
7b0ce8064a feat(files): interactive markdown checkbox toggling in preview (#3829)
* feat(files): interactive markdown checkbox toggling in preview

* fix(files): handle ordered-list checkboxes and fix index drift

* lint

* fix(files): remove counter offset that prevented checkbox toggling

* fix(files): apply task-list styling to ordered lists too

* fix(files): render single pass when interactive to avoid index drift

* fix(files): move useMemo above conditional return to fix Rules of Hooks

* fix(files): pass content directly to preview when not streaming to avoid stale frame
2026-03-28 14:26:29 -07:00
Theodore Li
0ea73263df feat(ui): handle image paste (#3826)
* feat(ui): handle image paste

* Fix lint

* Fix type error

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-28 17:15:33 -04:00
Waleed
edc502384b improvement(sidebar): expand sidebar by hovering and clicking the edge (#3830)
* improvement(sidebar): expand sidebar by hovering and clicking the edge

* improvement(sidebar): add keyboard shortcuts for new workflow/task, center search modal, fix edge ARIA

* improvement(sidebar): use Tooltip.Shortcut for inline shortcut display

* fix(sidebar): change new workflow shortcut from Mod+Shift+W to Mod+Shift+P to avoid browser close-window conflict

* fix(hotkeys): fall back to event.code for international keyboard layout compatibility

* fix(sidebar): guard add-workflow shortcut with canEdit and isCreatingWorkflow checks
2026-03-28 14:06:30 -07:00
Waleed
e2be99263c feat(academy): Sim Academy — interactive partner certification platform (#3824)
* fix(import): dedup workflow name (#3813)

* feat(concurrency): bullmq based concurrency control system (#3605)

* feat(concurrency): bullmq based queueing system

* fix bun lock

* remove manual execs off queues

* address comments

* fix legacy team limits

* cleanup enterprise typing code

* inline child triggers

* fix status check

* address more comments

* optimize reconciler scan

* remove dead code

* add to landing page

* Add load testing framework

* update bullmq

* fix

* fix headless path

---------

Co-authored-by: Theodore Li <teddy@zenobiapay.com>

* fix(linear): add default null for after cursor (#3814)

* fix(knowledge): reject non-alphanumeric file extensions from document names (#3816)

* fix(knowledge): reject non-alphanumeric file extensions from document names

* fix(knowledge): improve error message when extension is non-alphanumeric

* fix(security): SSRF, access control, and info disclosure (#3815)

* fix(security): scope copilot feedback GET endpoint to authenticated user

Add WHERE clause to filter feedback records by the authenticated user's
ID, preventing any authenticated user from reading all users' copilot
interactions, queries, and workflow YAML (IDOR / CWE-639).

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

* fix(smtp): add SSRF validation and genericize network error messages

Prevent SSRF via user-controlled smtpHost by validating with
validateDatabaseHost before creating the nodemailer transporter.
Collapse distinct network error messages (ECONNREFUSED, ECONNRESET,
ETIMEDOUT) into a single generic message to prevent port-state leakage.

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

* fix(security): add SSRF validation to SFTP/SSH and access control to workspace invitations

Add `validateDatabaseHost` checks to SFTP and SSH connection utilities to
block connections to private/reserved IPs and localhost, matching the
existing pattern used by all database tools. Add authorization check to
the workspace invitation GET endpoint so only the invitee or a workspace
admin can view invitation details.

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

* fix(smtp): restore SMTP response code handling for post-connection errors

SMTP 4xx/5xx response codes are application-level errors (invalid
recipient, mailbox full, server error) unrelated to the SSRF hardening
goal. Restore response code differentiation and logging to preserve
actionable user-facing error messages.

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

* fix(security): use session email directly instead of extra DB query

Addresses PR review feedback — align with the workspace invitation
route pattern by using session.user.email instead of re-fetching
from the database.

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

* lint

* fix(auth): revert lint autofix that broke hasExternalApiCredentials return type

Biome auto-fixed `return auth !== null && auth.startsWith(...)` to
`return auth?.startsWith(...)` which returns `boolean | undefined`,
not `boolean`, causing a TypeScript build failure.

* fix(smtp): pin resolved IP to prevent DNS rebinding (TOCTOU)

Use the pre-resolved IP from validateDatabaseHost instead of the
original hostname when creating the nodemailer transporter. Set
servername to the original hostname to preserve TLS SNI validation.

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

* refactor(security): extract createPinnedLookup helper for DNS rebinding prevention

Extract reusable createPinnedLookup from secureFetchWithPinnedIP so
non-HTTP transports (SSH, SFTP, IMAP) can pin resolved IPs at the
socket level. SMTP route uses host+servername pinning instead since
nodemailer doesn't reliably pass lookup to both secure/plaintext paths.

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

* fix(security): pin IMAP connections to validated resolved IP

Pass the resolved IP from validateDatabaseHost to ImapFlow as host,
with the original hostname as servername for TLS SNI verification.
Closes the DNS TOCTOU rebinding window.

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

* lint

* fix(auth): revert lint autofix on hasExternalApiCredentials return type

Also pin SFTP/SSH connections to validated resolved IP to prevent DNS rebinding.

* fix(security): short-circuit admin check when caller is invitee

Skip the hasWorkspaceAdminAccess DB query when the caller is already
the invitee, avoiding an unnecessary round-trip. Aligns with the org
invitation route pattern.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* fix(worker): dockerfile + helm updates (#3818)

* fix(worker): dockerfile + helm updates

* address comments

* update dockerfile (#3819)

* fix dockerfile

* fix(security): pentest remediation — condition escaping, SSRF hardening, ReDoS protection (#3820)

* fix(executor): escape newline characters in condition expression strings

Unescaped newline/carriage-return characters in resolved string values
cause unterminated string literals in generated JS, crashing condition
evaluation with a SyntaxError.

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

* fix(security): prevent ReDoS in guardrails regex validation

Add safe-regex2 to reject catastrophic backtracking patterns before
execution and cap input length at 10k characters.

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

* fix(security): SSRF localhost hardening and regex DoS protection

Block localhost/loopback URLs in hosted environments using isHosted flag
instead of allowHttp. Add safe-regex2 validation and input length limits
to regex guardrails to prevent catastrophic backtracking.

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

* fix(security): validate regex syntax before safety check

Move new RegExp() before safe() so invalid patterns get a proper syntax
error instead of a misleading "catastrophic backtracking" message.

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

* fix(security): address PR review feedback

- Hoist isLocalhost && isHosted guard to single early-return before
  protocol checks, removing redundant duplicate block
- Move regex syntax validation (new RegExp) before safe-regex2 check
  so invalid patterns get proper syntax error instead of misleading
  "catastrophic backtracking" message

* fix(security): remove input length cap from regex validation

The 10k character cap would block legitimate guardrail checks on long
LLM outputs. Input length doesn't affect ReDoS risk — the safe-regex2
pattern check already prevents catastrophic backtracking.

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

* fix(tests): mock isHosted in input-validation and function-execute tests

Tests that assert self-hosted localhost behavior need isHosted=false,
which is not guaranteed in CI where NEXT_PUBLIC_APP_URL is set to the
hosted domain.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* improvement(worker): configuration defaults (#3821)

* improvement(worker): configuration defaults

* update readmes

* realtime curl import

* improvement(tour): remove auto-start, only trigger on explicit user action (#3823)

* fix(mcp): use correct modal for creating workflow MCP servers in deploy (#3822)

* fix(mcp): use correct modal for creating workflow MCP servers in deploy

* fix(mcp): show workflows field during loading and when empty

* mock course

* fix(db): use bigint for token counter columns in user_stats (#3755)

* mock course

* updates

* updated X handle for emir

* cleanup: audit and clean academy implementation

* fix(academy): add label to ValidationRule, fix quiz gating, simplify getRuleMessage

* cleanup: remove unnecessary comments across academy files

* refactor(academy): simplify abstractions and fix perf issues

* perf(academy): convert course detail page to server component with client island

* fix(academy): null-safe canAdvance, render exercise instructions, remove stale comments

* fix(academy): remove orphaned migration, fix getCourseById, clean up comments

- Delete 0181_academy_certificate.sql (orphaned duplicate not in journal)
- Add getCourseById() to content/index.ts; use it in certificates API
  (was using getCourse which searches by slug, not stable id)
- Remove JSX comments from catalog page
- Remove redundant `passed` recomputation in LessonQuiz

* chore(db): regenerate academy_certificate migration with drizzle-kit

* chore: include blog mdx and components changes

* fix(blog): correct cn import path

* fix(academy): constrain progress bar to max-w-3xl with proper padding

* feat(academy): show back-to-course button on first lesson

* fix(academy): force dark theme on all /academy routes

* content(academy): rewrite sim-foundations course with full 6-module curriculum

* fix(academy): correct edge handles, quiz explanation, and starter mock outputs

- Fix Exercise 2 initial edge handles: 'starter-1-source'/'agent-1-target' → 'source'/'target' (React Flow actual IDs)
- Fix M1-L4 Q4 quiz explanation: remove non-existent Ctrl/Cmd+D and Alt+drag shortcuts
- Add starter mock output to all exercises so run animation shows feedback on the first block

* refine(academy): fix inaccurate content and improve exercise clarity

- Fix Exercise 3: replace hardcoded <agent-1.content> (invalid UUID-based ref) with reference picker instructions
- Fix M4 Quiz Q5: Loop block (subflow container) is correct answer, not the Workflow block
- Fix M4 Quiz Q4: clarify fan-out vs Parallel block distinction in explanation
- Fix M4-L2 video description: accurately describe Loop and Parallel subflow blocks
- Fix M2 Quiz Q3: make response format question conceptual rather than syntax-specific
- Improve Exercise 4 branching instructions: clarify top=true / bottom=false output handles
- Improve Final Project instructions: step-by-step numbered flow

* fix(academy): remove double border on quiz question cards

* fix(academy): single scroll container on lesson pages — remove nested flex scroll

* fix(academy): remove min-h-screen from root layout — fixes double scrollbar on lesson pages

* fix(academy): use fixed inset-0 on lesson page to eliminate document-level scrollbar

* fix(academy): replace sr-only radio/checkbox inputs with buttons to prevent scroll-on-focus; restore layout min-h-screen

* improvement(academy): polish, security hardening, and certificate claim UI

- Replace raw localStorage with BrowserStorage utility in local-progress
- Pre-compute slug/id Maps in content/index for O(1) course lookups
- Move blockMap construction into edge_exists branch only in validation
- Extract navBtnClass constant and MetaRow/formatDate helpers in UI
- Add rate limiting, server-side completion verification, audit logging, and nanoid cert numbers to certificate issuance endpoint
- Add useIssueCertificate mutation hook with completedLessonIds
- Wire certificate claim UI into CourseProgress: sign-in prompt, claim button with loading state, and post-issuance view with link to certificate page
- Fix lesson page scroll container and quiz scroll-on-focus bug

* fix(academy): validate condition branch handles in edge_exists rules

- Add sourceHandle field to edge_exists ValidationRule type
- Check sourceHandle in validation.ts when specified
- Require both condition-if and condition-else branches to be connected in the branching and final project exercises

* fix(academy): address PR review — isHosted regression, stuck isExecuting, revoked cert 500, certificate SSR

- Restore env-var-based isHosted check (was hardcoded true, breaking self-hosted deployments)
- Fix isExecuting stuck at true when mock run fails validation — set isMockRunningRef immediately and reset both flags on early exit
- Fix revoked/expired certificate causing 500 — any existing record (not just active) now returns 409 instead of falling through to INSERT
- Convert certificate verification page from client component to server component — direct DB fetch, notFound() on missing cert, generateMetadata for SEO/social previews

* fix(auth): restore hybrid.ts from staging to fix CI type error

* fix(academy): mark video lessons complete on visit and fix sign-in path

* fix(academy): replace useEffect+setState with lazy useState initializer in CourseProgress

* fix(academy): reset exerciseComplete on lesson navigation, remove unused useAcademyCertificate hook

* fix(academy): useState for slug-change reset, cache() for cert page, handleMockRunRef for stale closure

* fix(academy): replace shadcn theme vars with explicit hex in LessonVideo fallback

* fix(academy): reset completedRef on exercise change, conditional verified badge, multi-select empty guard

* fix(academy): type safety fixes — null metadata fallbacks, returning() guard, exhaustive union, empty catch

* fix(academy): reset ExerciseView completed banner on nav; fix CourseProgress hydration mismatch

* fix(lightbox): guard effect body with isOpen to prevent spurious overflow reset

* fix(academy): reset LessonQuiz state on lesson change to prevent stale answers persisting

* fix(academy): course not-found metadata title; try-finally guard in mock run loop

* fix(academy): type safety, cert persistence, regex guard, mixed-lesson video, shorts support

- Derive AcademyCertificate from db $inferSelect to prevent schema drift
- Add useCourseCertificate query hook; GET /api/academy/certificates now accepts courseId for authenticated lookup
- Use useCourseCertificate in CourseProgress so certificate state survives page refresh
- Guard new RegExp(valuePattern) in validation.ts with try/catch; log warn on invalid pattern
- Add logger.warn for custom validation rules so content authors are alerted
- Add YouTube Shorts URL support to LessonVideo (youtube.com/shorts/VIDEO_ID)
- Fix mixed-lesson video gap: render videoUrl above quiz when mixed has quiz but no exercise
- Add academy-scoped not-found.tsx with link back to /academy

* fix(academy): reset hintIndex when exercise changes

* chore: remove ban-spam-accounts script (wrong branch)

* fix(academy): enforce availableBlocks in toolbar; fix mixed exercise+quiz rendering

- Add useSandboxBlockConstraints context; SandboxCanvasProvider provides exerciseConfig.availableBlocks so the toolbar only shows permitted block types. Empty array hides all blocks (configure-only exercises); non-null array restricts to listed types; triggers always hidden in sandbox.
- Fix mixed lesson with both exerciseConfig and quizConfig: exercise renders first, quiz reveals after exercise completes (sequential pedagogy). canAdvance now requires both exerciseComplete && quizComplete when both are present.

* chore(academy): remove extraneous inline comments

* fix(academy): blank mixed lesson, quiz canAdvance flag, empty-array valueNotEmpty

* prep for merge

* chore(db): regenerate academy certificate migration after staging merge

* fix(academy): disable auto-connect in sandbox mode

* fix(academy): render video in mixed lesson with no exercise or quiz

* fix(academy): mark mixed video-only lessons complete; handle cert insert race

* fix(canvas): add sandbox and embedded to nodes useMemo deps

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Lakee Sivaraya <71339072+lakeesiv@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
Co-authored-by: Theodore Li <teddy@zenobiapay.com>
2026-03-28 12:46:04 -07:00
Waleed
f6b461ad47 fix(readme): restore readme gifs (#3827)
The static GIFs referenced by README.md were removed in #3803.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 12:36:23 -07:00
Waleed
e4d35735b1 fix(knowledge): give users choice to keep or delete documents when removing connector (#3825)
* fix(knowledge): give users choice to keep or delete documents when removing connector

* refactor(knowledge): clean up connector delete and extract shared extension validator

- Extract `isAlphanumericExtension` helper to deduplicate regex across parser-extension.ts and validation.ts
- Extract `closeDeleteModal` callback to eliminate 4x scattered state resets
- Add archivedAt/deletedAt filters to UPDATE query in keep-docs delete path
- Parallelize storage file cleanup and tag definition cleanup with Promise.all
- Deduplicate URL construction in delete connector hook

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

* refactor(knowledge): remove duplicate extension list from parser-extension

Use SUPPORTED_DOCUMENT_EXTENSIONS and isSupportedExtension from
validation.ts instead of maintaining a separate identical list.

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

* fix(db): change document.connectorId FK from cascade to set null

The cascade behavior meant deleting a connector would always delete
its documents, contradicting the "keep documents" option. With set null,
the database automatically nullifies connectorId when a connector is
removed, and we only need explicit deletion when the user opts in.

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

* chore(db): add migration metadata for connectorId FK change

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

* fix(knowledge): fix connector delete test and use URL-safe searchParams

Use `new URL(request.url).searchParams` instead of `request.nextUrl.searchParams`
for compatibility with test mocks. Add missing `connectorType` to test fixture.

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

* spacing

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 12:04:26 -07:00
Waleed
b4064c57fb fix(mcp): use correct modal for creating workflow MCP servers in deploy (#3822)
* fix(mcp): use correct modal for creating workflow MCP servers in deploy

* fix(mcp): show workflows field during loading and when empty
2026-03-27 20:22:30 -07:00
Waleed
eac41ca105 improvement(tour): remove auto-start, only trigger on explicit user action (#3823) 2026-03-27 19:59:47 -07:00
Vikhyath Mondreti
d2c3c1c39e improvement(worker): configuration defaults (#3821)
* improvement(worker): configuration defaults

* update readmes

* realtime curl import
2026-03-27 19:54:56 -07:00
Waleed
8f3e864751 fix(security): pentest remediation — condition escaping, SSRF hardening, ReDoS protection (#3820)
* fix(executor): escape newline characters in condition expression strings

Unescaped newline/carriage-return characters in resolved string values
cause unterminated string literals in generated JS, crashing condition
evaluation with a SyntaxError.

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

* fix(security): prevent ReDoS in guardrails regex validation

Add safe-regex2 to reject catastrophic backtracking patterns before
execution and cap input length at 10k characters.

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

* fix(security): SSRF localhost hardening and regex DoS protection

Block localhost/loopback URLs in hosted environments using isHosted flag
instead of allowHttp. Add safe-regex2 validation and input length limits
to regex guardrails to prevent catastrophic backtracking.

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

* fix(security): validate regex syntax before safety check

Move new RegExp() before safe() so invalid patterns get a proper syntax
error instead of a misleading "catastrophic backtracking" message.

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

* fix(security): address PR review feedback

- Hoist isLocalhost && isHosted guard to single early-return before
  protocol checks, removing redundant duplicate block
- Move regex syntax validation (new RegExp) before safe-regex2 check
  so invalid patterns get proper syntax error instead of misleading
  "catastrophic backtracking" message

* fix(security): remove input length cap from regex validation

The 10k character cap would block legitimate guardrail checks on long
LLM outputs. Input length doesn't affect ReDoS risk — the safe-regex2
pattern check already prevents catastrophic backtracking.

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

* fix(tests): mock isHosted in input-validation and function-execute tests

Tests that assert self-hosted localhost behavior need isHosted=false,
which is not guaranteed in CI where NEXT_PUBLIC_APP_URL is set to the
hosted domain.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 19:54:00 -07:00
Vikhyath Mondreti
23c3072784 fix dockerfile 2026-03-27 19:11:02 -07:00
Vikhyath Mondreti
33fdb11396 update dockerfile (#3819) 2026-03-27 18:50:57 -07:00
Vikhyath Mondreti
21156dd54a fix(worker): dockerfile + helm updates (#3818)
* fix(worker): dockerfile + helm updates

* address comments
2026-03-27 18:28:36 -07:00
Waleed
c05e2e0fc8 fix(security): SSRF, access control, and info disclosure (#3815)
* fix(security): scope copilot feedback GET endpoint to authenticated user

Add WHERE clause to filter feedback records by the authenticated user's
ID, preventing any authenticated user from reading all users' copilot
interactions, queries, and workflow YAML (IDOR / CWE-639).

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

* fix(smtp): add SSRF validation and genericize network error messages

Prevent SSRF via user-controlled smtpHost by validating with
validateDatabaseHost before creating the nodemailer transporter.
Collapse distinct network error messages (ECONNREFUSED, ECONNRESET,
ETIMEDOUT) into a single generic message to prevent port-state leakage.

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

* fix(security): add SSRF validation to SFTP/SSH and access control to workspace invitations

Add `validateDatabaseHost` checks to SFTP and SSH connection utilities to
block connections to private/reserved IPs and localhost, matching the
existing pattern used by all database tools. Add authorization check to
the workspace invitation GET endpoint so only the invitee or a workspace
admin can view invitation details.

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

* fix(smtp): restore SMTP response code handling for post-connection errors

SMTP 4xx/5xx response codes are application-level errors (invalid
recipient, mailbox full, server error) unrelated to the SSRF hardening
goal. Restore response code differentiation and logging to preserve
actionable user-facing error messages.

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

* fix(security): use session email directly instead of extra DB query

Addresses PR review feedback — align with the workspace invitation
route pattern by using session.user.email instead of re-fetching
from the database.

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

* lint

* fix(auth): revert lint autofix that broke hasExternalApiCredentials return type

Biome auto-fixed `return auth !== null && auth.startsWith(...)` to
`return auth?.startsWith(...)` which returns `boolean | undefined`,
not `boolean`, causing a TypeScript build failure.

* fix(smtp): pin resolved IP to prevent DNS rebinding (TOCTOU)

Use the pre-resolved IP from validateDatabaseHost instead of the
original hostname when creating the nodemailer transporter. Set
servername to the original hostname to preserve TLS SNI validation.

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

* refactor(security): extract createPinnedLookup helper for DNS rebinding prevention

Extract reusable createPinnedLookup from secureFetchWithPinnedIP so
non-HTTP transports (SSH, SFTP, IMAP) can pin resolved IPs at the
socket level. SMTP route uses host+servername pinning instead since
nodemailer doesn't reliably pass lookup to both secure/plaintext paths.

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

* fix(security): pin IMAP connections to validated resolved IP

Pass the resolved IP from validateDatabaseHost to ImapFlow as host,
with the original hostname as servername for TLS SNI verification.
Closes the DNS TOCTOU rebinding window.

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

* lint

* fix(auth): revert lint autofix on hasExternalApiCredentials return type

Also pin SFTP/SSH connections to validated resolved IP to prevent DNS rebinding.

* fix(security): short-circuit admin check when caller is invitee

Skip the hasWorkspaceAdminAccess DB query when the caller is already
the invitee, avoiding an unnecessary round-trip. Aligns with the org
invitation route pattern.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 18:10:47 -07:00
833 changed files with 100879 additions and 12291 deletions

View File

@@ -192,7 +192,7 @@ In the block config (`blocks/blocks/{service}.ts`), add `hideWhenHosted: true` t
},
```
The visibility is controlled by `isSubBlockHiddenByHostedKey()` in `lib/workflows/subblocks/visibility.ts`, which checks the `isHosted` feature flag.
The visibility is controlled by `isSubBlockHidden()` in `lib/workflows/subblocks/visibility.ts`, which checks both the `isHosted` feature flag (`hideWhenHosted`) and optional env var conditions (`hideWhenEnvSet`).
### Excluding Specific Operations from Hosted Key Support

View File

@@ -2,9 +2,9 @@ name: CI
on:
push:
branches: [main, staging]
branches: [main, staging, dev]
pull_request:
branches: [main, staging]
branches: [main, staging, dev]
concurrency:
group: ci-${{ github.ref }}
@@ -23,7 +23,7 @@ jobs:
detect-version:
name: Detect Version
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging')
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging' || github.ref == 'refs/heads/dev')
outputs:
version: ${{ steps.extract.outputs.version }}
is_release: ${{ steps.extract.outputs.is_release }}
@@ -49,7 +49,7 @@ jobs:
build-amd64:
name: Build AMD64
needs: [test-build, detect-version]
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging')
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging' || github.ref == 'refs/heads/dev')
runs-on: blacksmith-8vcpu-ubuntu-2404
permissions:
contents: read
@@ -75,8 +75,8 @@ jobs:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }}
aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || secrets.STAGING_AWS_REGION }}
role-to-assume: ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || github.ref == 'refs/heads/dev' && secrets.DEV_AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }}
aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || github.ref == 'refs/heads/dev' && secrets.DEV_AWS_REGION || secrets.STAGING_AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
@@ -109,6 +109,8 @@ jobs:
# ECR tags (always build for ECR)
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
ECR_TAG="latest"
elif [ "${{ github.ref }}" = "refs/heads/dev" ]; then
ECR_TAG="dev"
else
ECR_TAG="staging"
fi

View File

@@ -36,8 +36,8 @@ jobs:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }}
aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || secrets.STAGING_AWS_REGION }}
role-to-assume: ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || github.ref == 'refs/heads/dev' && secrets.DEV_AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }}
aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || github.ref == 'refs/heads/dev' && secrets.DEV_AWS_REGION || secrets.STAGING_AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
@@ -70,6 +70,8 @@ jobs:
# ECR tags (always build for ECR)
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
ECR_TAG="latest"
elif [ "${{ github.ref }}" = "refs/heads/dev" ]; then
ECR_TAG="dev"
else
ECR_TAG="staging"
fi

View File

@@ -74,6 +74,10 @@ docker compose -f docker-compose.prod.yml up -d
Open [http://localhost:3000](http://localhost:3000)
#### Background worker note
The Docker Compose stack starts a dedicated worker container by default. If `REDIS_URL` is not configured, the worker will start, log that it is idle, and do no queue processing. This is expected. Queue-backed API, webhook, and schedule execution requires Redis; installs without Redis continue to use the inline execution path.
Sim also supports local models via [Ollama](https://ollama.ai) and [vLLM](https://docs.vllm.ai/) — see the [Docker self-hosting docs](https://docs.sim.ai/self-hosting/docker) for setup details.
### Self-hosted: Manual Setup
@@ -113,10 +117,12 @@ cd packages/db && bunx drizzle-kit migrate --config=./drizzle.config.ts
5. Start development servers:
```bash
bun run dev:full # Starts both Next.js app and realtime socket server
bun run dev:full # Starts Next.js app, realtime socket server, and the BullMQ worker
```
Or run separately: `bun run dev` (Next.js) and `cd apps/sim && bun run dev:sockets` (realtime).
If `REDIS_URL` is not configured, the worker will remain idle and execution continues inline.
Or run separately: `bun run dev` (Next.js), `cd apps/sim && bun run dev:sockets` (realtime), and `cd apps/sim && bun run worker` (BullMQ worker).
## Copilot API Keys

View File

@@ -18,7 +18,7 @@ export const metadata = {
metadataBase: new URL('https://docs.sim.ai'),
title: {
default: 'Sim Documentation — Build AI Agents & Run Your Agentic Workforce',
template: '%s',
template: '%s | Sim Docs',
},
description:
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',

File diff suppressed because one or more lines are too long

View File

@@ -5,6 +5,7 @@
import type { ComponentType, SVGProps } from 'react'
import {
A2AIcon,
AgentMailIcon,
AhrefsIcon,
AirtableIcon,
AirweaveIcon,
@@ -45,6 +46,7 @@ import {
EnrichSoIcon,
EvernoteIcon,
ExaAIIcon,
ExtendIcon,
EyeIcon,
FathomIcon,
FirecrawlIcon,
@@ -91,6 +93,7 @@ import {
KalshiIcon,
KetchIcon,
LangsmithIcon,
LaunchDarklyIcon,
LemlistIcon,
LinearIcon,
LinkedInIcon,
@@ -126,6 +129,7 @@ import {
PolymarketIcon,
PostgresIcon,
PosthogIcon,
ProfoundIcon,
PulseIcon,
QdrantIcon,
QuiverIcon,
@@ -136,9 +140,11 @@ import {
ResendIcon,
RevenueCatIcon,
RipplingIcon,
RootlyIcon,
S3Icon,
SalesforceIcon,
SearchIcon,
SecretsManagerIcon,
SendgridIcon,
SentryIcon,
SerperIcon,
@@ -154,6 +160,7 @@ import {
StagehandIcon,
StripeIcon,
SupabaseIcon,
TailscaleIcon,
TavilyIcon,
TelegramIcon,
TextractIcon,
@@ -183,6 +190,7 @@ type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
export const blockTypeToIconMap: Record<string, IconComponent> = {
a2a: A2AIcon,
agentmail: AgentMailIcon,
ahrefs: AhrefsIcon,
airtable: AirtableIcon,
airweave: AirweaveIcon,
@@ -220,6 +228,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
enrich: EnrichSoIcon,
evernote: EvernoteIcon,
exa: ExaAIIcon,
extend_v2: ExtendIcon,
fathom: FathomIcon,
file_v3: DocumentIcon,
firecrawl: FirecrawlIcon,
@@ -268,6 +277,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
ketch: KetchIcon,
knowledge: PackageSearchIcon,
langsmith: LangsmithIcon,
launchdarkly: LaunchDarklyIcon,
lemlist: LemlistIcon,
linear: LinearIcon,
linkedin: LinkedInIcon,
@@ -302,6 +312,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
polymarket: PolymarketIcon,
postgresql: PostgresIcon,
posthog: PosthogIcon,
profound: ProfoundIcon,
pulse_v2: PulseIcon,
qdrant: QdrantIcon,
quiver: QuiverIcon,
@@ -312,9 +323,11 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
resend: ResendIcon,
revenuecat: RevenueCatIcon,
rippling: RipplingIcon,
rootly: RootlyIcon,
s3: S3Icon,
salesforce: SalesforceIcon,
search: SearchIcon,
secrets_manager: SecretsManagerIcon,
sendgrid: SendgridIcon,
sentry: SentryIcon,
serper: SerperIcon,
@@ -331,6 +344,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
stripe: StripeIcon,
stt_v2: STTIcon,
supabase: SupabaseIcon,
tailscale: TailscaleIcon,
tavily: TavilyIcon,
telegram: TelegramIcon,
textract_v2: TextractIcon,

View File

@@ -131,7 +131,7 @@ Erkennt personenbezogene Daten mithilfe von Microsoft Presidio. Unterstützt üb
**Anwendungsfälle:**
- Blockieren von Inhalten mit sensiblen persönlichen Informationen
- Maskieren von personenbezogenen Daten vor der Protokollierung oder Speicherung
- Einhaltung der DSGVO, HIPAA und anderer Datenschutzbestimmungen
- Einhaltung der DSGVO und anderer Datenschutzbestimmungen
- Bereinigung von Benutzereingaben vor der Verarbeitung
## Konfiguration

View File

@@ -132,7 +132,7 @@ Detects personally identifiable information using Microsoft Presidio. Supports o
**Use Cases:**
- Block content containing sensitive personal information
- Mask PII before logging or storing data
- Compliance with GDPR, HIPAA, and other privacy regulations
- Compliance with GDPR and other privacy regulations
- Sanitize user inputs before processing
## Configuration

View File

@@ -20,7 +20,7 @@ The Response block formats and sends structured HTTP responses back to API calle
</div>
<Callout type="info">
Response blocks are terminal blocks - they end workflow execution and cannot connect to other blocks.
Response blocks are exit points — when a Response block executes, it ends the workflow and sends the HTTP response immediately. Multiple Response blocks can be placed on different branches (e.g. after a Router or Condition), but only the first one to execute determines the API response.
</Callout>
## Configuration Options
@@ -77,7 +77,11 @@ Condition (Error Detected) → Router → Response (400/500, Error Details)
## Outputs
Response blocks are terminal — no downstream blocks execute after them. However, the block does define outputs (`data`, `status`, `headers`) which are used to construct the HTTP response sent back to the API caller.
Response blocks are exit points — when one executes, no further blocks run. The block defines outputs (`data`, `status`, `headers`) which are used to construct the HTTP response sent back to the API caller.
<Callout type="warning">
If a Response block is placed on a parallel branch, there are no guarantees about whether other parallel blocks will run or not. Execution order across parallel branches is non-deterministic, so a parallel block may execute before or after the Response block on any given run. Avoid placing Response blocks in parallel with blocks that have important side effects.
</Callout>
## Variable References
@@ -110,10 +114,10 @@ Use the `<variable.name>` syntax to dynamically insert workflow variables into y
- **Validate variable references**: Ensure all referenced variables exist and contain the expected data types before the Response block executes
<FAQ items={[
{ question: "Can I have multiple Response blocks in a workflow?", answer: "No. The Response block is a single-instance block — only one is allowed per workflow. If you need different responses for different conditions, use a Condition or Router block upstream to determine what data reaches the single Response block." },
{ question: "Can I have multiple Response blocks in a workflow?", answer: "Yes. You can place multiple Response blocks on different branches (e.g. after a Router or Condition block). The first Response block to execute determines the API response and ends the workflow. This is useful for returning different responses based on conditions — for example, a 200 on the success branch and a 500 on the error branch." },
{ question: "What triggers require a Response block?", answer: "The Response block is designed for use with the API Trigger. When your workflow is invoked via the API, the Response block sends the structured HTTP response back to the caller. Other trigger types (like webhooks or schedules) do not require a Response block." },
{ question: "What is the difference between Builder and Editor mode?", answer: "Builder mode provides a visual interface for constructing your response structure with fields and types. Editor mode gives you a raw JSON code editor where you can write the response body directly. Builder mode is recommended for most use cases." },
{ question: "What is the default status code?", answer: "If you do not specify a status code, the Response block defaults to 200 (OK). You can set any valid HTTP status code including error codes like 400, 404, or 500." },
{ question: "Can the Response block connect to downstream blocks?", answer: "No. Response blocks are terminal — they end workflow execution and send the HTTP response. No further blocks can be connected after a Response block." },
{ question: "Can the Response block connect to downstream blocks?", answer: "No. Response blocks are exit points — they end workflow execution and send the HTTP response. No further blocks can execute after a Response block." },
]} />

View File

@@ -0,0 +1,206 @@
---
title: Google Service Accounts
description: Set up Google service accounts with domain-wide delegation for Gmail, Sheets, Drive, Calendar, and other Google services
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Step, Steps } from 'fumadocs-ui/components/steps'
import { Image } from '@/components/ui/image'
import { FAQ } from '@/components/ui/faq'
Google service accounts with domain-wide delegation let your workflows access Google APIs on behalf of users in your Google Workspace domain — without requiring each user to complete an OAuth consent flow. This is ideal for automated workflows that need to send emails, read spreadsheets, or manage files across your organization.
For example, you could build a workflow that iterates through a list of employees, impersonates each one to read their Google Docs, and uploads the contents to a shared knowledge base — all without requiring any of those users to sign in.
## Prerequisites
Before adding a service account to Sim, you need to configure it in the Google Cloud Console and Google Workspace Admin Console.
### 1. Create a Service Account in Google Cloud
<Steps>
<Step>
Go to the [Google Cloud Console](https://console.cloud.google.com/) and select your project (or create one)
</Step>
<Step>
Navigate to **IAM & Admin** → **Service Accounts**
</Step>
<Step>
Click **Create Service Account**, give it a name and description, then click **Create and Continue**
<div className="flex justify-center">
<Image
src="/static/credentials/gcp-create-service-account.png"
alt="Google Cloud Console — Create service account form"
width={700}
height={500}
className="my-4"
/>
</div>
</Step>
<Step>
Skip the optional role and user access steps and click **Done**
</Step>
<Step>
Click on the newly created service account, go to the **Keys** tab, and click **Add Key** → **Create new key**
</Step>
<Step>
Select **JSON** as the key type and click **Create**. A JSON key file will download — keep this safe
<div className="flex justify-center">
<Image
src="/static/credentials/gcp-create-private-key.png"
alt="Google Cloud Console — Create private key dialog with JSON selected"
width={700}
height={400}
className="my-4"
/>
</div>
</Step>
</Steps>
<Callout type="warn">
The JSON key file contains your service account's private key. Treat it like a password — do not commit it to source control or share it publicly.
</Callout>
### 2. Enable the Required APIs
In the Google Cloud Console, go to **APIs & Services** → **Library** and enable the APIs for the services your workflows will use. See the [scopes reference](#scopes-reference) below for the full list of APIs by service.
### 3. Set Up Domain-Wide Delegation
<Steps>
<Step>
In the Google Cloud Console, go to **IAM & Admin** → **Service Accounts**, click on your service account, and copy the **Client ID** (the numeric ID, not the email)
</Step>
<Step>
Open the [Google Workspace Admin Console](https://admin.google.com/) and navigate to **Security** → **Access and data control** → **API controls**
</Step>
<Step>
Click **Manage Domain Wide Delegation**, then click **Add new**
</Step>
<Step>
Paste the **Client ID** from your service account, then add the OAuth scopes for the services your workflows need. Copy the full scope URLs from the [scopes reference](#scopes-reference) below — only authorize scopes for services you plan to use.
<div className="flex justify-center">
<Image
src="/static/credentials/gcp-add-client-id.png"
alt="Google Workspace Admin Console — Add a new client ID with OAuth scopes"
width={350}
height={300}
className="my-4"
/>
</div>
</Step>
<Step>
Click **Authorize**
</Step>
</Steps>
<Callout type="info">
Domain-wide delegation must be configured by a Google Workspace admin. If you are not an admin, send the Client ID and required scopes to your admin.
</Callout>
### Scopes Reference
The table below lists every Google service that supports service account authentication in Sim, the API to enable in Google Cloud Console, and the delegation scopes to authorize. Copy the scope string for each service you need and paste it into the Google Workspace Admin Console.
<table>
<thead>
<tr>
<th className="whitespace-nowrap">Service</th>
<th className="whitespace-nowrap">API to Enable</th>
<th>Delegation Scopes</th>
</tr>
</thead>
<tbody>
<tr><td>Gmail</td><td>Gmail API</td><td><code>{'https://www.googleapis.com/auth/gmail.send'}</code><br/><code>{'https://www.googleapis.com/auth/gmail.modify'}</code><br/><code>{'https://www.googleapis.com/auth/gmail.labels'}</code></td></tr>
<tr><td>Google Sheets</td><td>Google Sheets API, Google Drive API</td><td><code>{'https://www.googleapis.com/auth/drive'}</code><br/><code>{'https://www.googleapis.com/auth/drive.file'}</code></td></tr>
<tr><td>Google Drive</td><td>Google Drive API</td><td><code>{'https://www.googleapis.com/auth/drive'}</code><br/><code>{'https://www.googleapis.com/auth/drive.file'}</code></td></tr>
<tr><td>Google Docs</td><td>Google Docs API, Google Drive API</td><td><code>{'https://www.googleapis.com/auth/drive'}</code><br/><code>{'https://www.googleapis.com/auth/drive.file'}</code></td></tr>
<tr><td>Google Slides</td><td>Google Slides API, Google Drive API</td><td><code>{'https://www.googleapis.com/auth/drive'}</code><br/><code>{'https://www.googleapis.com/auth/drive.file'}</code></td></tr>
<tr><td>Google Forms</td><td>Google Forms API, Google Drive API</td><td><code>{'https://www.googleapis.com/auth/drive'}</code><br/><code>{'https://www.googleapis.com/auth/forms.body'}</code><br/><code>{'https://www.googleapis.com/auth/forms.responses.readonly'}</code></td></tr>
<tr><td>Google Calendar</td><td>Google Calendar API</td><td><code>{'https://www.googleapis.com/auth/calendar'}</code></td></tr>
<tr><td>Google Contacts</td><td>People API</td><td><code>{'https://www.googleapis.com/auth/contacts'}</code></td></tr>
<tr><td>BigQuery</td><td>BigQuery API</td><td><code>{'https://www.googleapis.com/auth/bigquery'}</code></td></tr>
<tr><td>Google Tasks</td><td>Tasks API</td><td><code>{'https://www.googleapis.com/auth/tasks'}</code></td></tr>
<tr><td>Google Vault</td><td>Vault API, Cloud Storage API</td><td><code>{'https://www.googleapis.com/auth/ediscovery'}</code><br/><code>{'https://www.googleapis.com/auth/devstorage.read_only'}</code></td></tr>
<tr><td>Google Groups</td><td>Admin SDK API</td><td><code>{'https://www.googleapis.com/auth/admin.directory.group'}</code><br/><code>{'https://www.googleapis.com/auth/admin.directory.group.member'}</code></td></tr>
<tr><td>Google Meet</td><td>Google Meet API</td><td><code>{'https://www.googleapis.com/auth/meetings.space.created'}</code><br/><code>{'https://www.googleapis.com/auth/meetings.space.readonly'}</code></td></tr>
</tbody>
</table>
<Callout type="info">
You only need to enable APIs and authorize scopes for the services you plan to use. When authorizing multiple services, combine their scope strings with commas into a single entry in the Admin Console.
</Callout>
## Adding the Service Account to Sim
Once Google Cloud and Workspace are configured, add the service account as a credential in Sim.
<Steps>
<Step>
Open your workspace **Settings** and go to the **Integrations** tab
</Step>
<Step>
Search for "Google Service Account" and click **Connect**
<div className="flex justify-center">
<Image
src="/static/credentials/integrations-service-account.png"
alt="Integrations page showing Google Service Account"
width={800}
height={150}
className="my-4"
/>
</div>
</Step>
<Step>
Paste the full contents of your JSON key file into the text area
<div className="flex justify-center">
<Image
src="/static/credentials/add-service-account.png"
alt="Add Google Service Account dialog"
width={350}
height={420}
className="my-6"
/>
</div>
</Step>
<Step>
Give the credential a display name (the service account email is used by default)
</Step>
<Step>
Click **Save**
</Step>
</Steps>
The JSON key file is validated for the required fields (`type`, `client_email`, `private_key`, `project_id`) and encrypted before being stored.
## Using Delegated Access in Workflows
When you use a Google block (Gmail, Sheets, Drive, etc.) in a workflow and select a service account credential, an **Impersonate User Email** field appears below the credential selector.
Enter the email address of the Google Workspace user you want the service account to act as. For example, if you enter `alice@yourcompany.com`, the workflow will send emails from Alice's account, read her spreadsheets, or access her calendar — depending on the scopes you authorized.
<div className="flex justify-center">
<Image
src="/static/credentials/workflow-impersonated-account.png"
alt="Gmail block in a workflow showing the Impersonated Account field with a service account credential"
width={800}
height={350}
className="my-4"
/>
</div>
<Callout type="warn">
The impersonated email must belong to a user in the Google Workspace domain where you configured domain-wide delegation. Impersonating external email addresses will fail.
</Callout>
<FAQ items={[
{ question: "Can I use a service account without domain-wide delegation?", answer: "Yes, but it will only be able to access resources owned by the service account itself (e.g., spreadsheets shared directly with the service account email). Without delegation, you cannot impersonate users or access their personal data like Gmail." },
{ question: "What happens if the impersonation email field is left blank?", answer: "The service account will authenticate as itself. This works for accessing shared resources (like a Google Sheet shared with the service account email) but will fail for user-specific APIs like Gmail." },
{ question: "Can I use the same service account for multiple Google services?", answer: "Yes. A single service account can be used across Gmail, Sheets, Drive, Calendar, and other Google services — as long as the required API is enabled in Google Cloud and the corresponding scopes are authorized in the Workspace admin console." },
{ question: "How do I rotate the service account key?", answer: "Create a new JSON key in the Google Cloud Console under your service account's Keys tab, then update the credential in Sim with the new key. Delete the old key from Google Cloud once the new one is working." },
{ question: "Does the impersonated user need a Google Workspace license?", answer: "Yes. Domain-wide delegation only works with users who have a Google Workspace account in the domain. Consumer Gmail accounts (e.g., @gmail.com) cannot be impersonated." },
]} />

View File

@@ -0,0 +1,5 @@
{
"title": "Credentials",
"pages": ["index", "google-service-account"],
"defaultOpen": false
}

View File

@@ -96,8 +96,9 @@ Understanding these core principles will help you build better workflows:
2. **Automatic Parallelization**: Independent blocks run concurrently without configuration
3. **Smart Data Flow**: Outputs flow automatically to connected blocks
4. **Error Handling**: Failed blocks stop their execution path but don't affect independent paths
5. **State Persistence**: All block outputs and execution details are preserved for debugging
6. **Cycle Protection**: Workflows that call other workflows (via Workflow blocks, MCP tools, or API blocks) are tracked with a call chain. If the chain exceeds 25 hops, execution is stopped to prevent infinite loops
5. **Response Blocks as Exit Points**: When a Response block executes, the entire workflow stops and the API response is sent immediately. Multiple Response blocks can exist on different branches — the first one to execute wins
6. **State Persistence**: All block outputs and execution details are preserved for debugging
7. **Cycle Protection**: Workflows that call other workflows (via Workflow blocks, MCP tools, or API blocks) are tracked with a call chain. If the chain exceeds 25 hops, execution is stopped to prevent infinite loops
## Next Steps

View File

@@ -0,0 +1,592 @@
---
title: AgentMail
description: Manage email inboxes, threads, and messages with AgentMail
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="agentmail"
color="#000000"
/>
{/* MANUAL-CONTENT-START:intro */}
[AgentMail](https://agentmail.to/) is an API-first email platform built for agents and automation. AgentMail lets you create email inboxes on the fly, send and receive messages, reply to threads, manage drafts, and organize conversations with labels — all through a simple REST API designed for programmatic access.
**Why AgentMail?**
- **Agent-Native Email:** Purpose-built for AI agents and automation — create inboxes, send messages, and manage threads without human-facing UI overhead.
- **Full Email Lifecycle:** Send new messages, reply to threads, forward emails, manage drafts, and schedule sends — all from a single API.
- **Thread & Conversation Management:** Organize emails into threads with full read, reply, forward, and label support for structured conversation tracking.
- **Draft Workflow:** Compose drafts, update them, schedule sends, and dispatch when ready — perfect for review-before-send workflows.
- **Label Organization:** Tag threads and messages with custom labels for filtering, routing, and downstream automation.
**Using AgentMail in Sim**
Sim's AgentMail integration connects your agentic workflows directly to AgentMail using an API key. With 20 operations spanning inboxes, threads, messages, and drafts, you can build powerful email automations without writing backend code.
**Key benefits of using AgentMail in Sim:**
- **Dynamic inbox creation:** Spin up new inboxes on the fly for each agent, workflow, or customer — perfect for multi-tenant email handling.
- **Automated email processing:** List and read incoming messages, then trigger downstream actions based on content, sender, or labels.
- **Conversational email:** Reply to threads and forward messages to keep conversations flowing naturally within your automated workflows.
- **Draft and review workflows:** Create drafts, update them with AI-generated content, and send when approved — ideal for human-in-the-loop patterns.
- **Email organization:** Apply labels to threads and messages to categorize, filter, and route emails through your automation pipeline.
Whether you're building an AI email assistant, automating customer support replies, processing incoming leads, or managing multi-agent email workflows, AgentMail in Sim gives you direct, secure access to the full AgentMail API — no middleware required. Simply configure your API key, select the operation you need, and let Sim handle the rest.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate AgentMail into your workflow. Create and manage email inboxes, send and receive messages, reply to threads, manage drafts, and organize threads with labels. Requires API Key.
## Tools
### `agentmail_create_draft`
Create a new email draft in AgentMail
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentMail API key |
| `inboxId` | string | Yes | ID of the inbox to create the draft in |
| `to` | string | No | Recipient email addresses \(comma-separated\) |
| `subject` | string | No | Draft subject line |
| `text` | string | No | Plain text draft body |
| `html` | string | No | HTML draft body |
| `cc` | string | No | CC recipient email addresses \(comma-separated\) |
| `bcc` | string | No | BCC recipient email addresses \(comma-separated\) |
| `inReplyTo` | string | No | ID of message being replied to |
| `sendAt` | string | No | ISO 8601 timestamp to schedule sending |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `draftId` | string | Unique identifier for the draft |
| `inboxId` | string | Inbox the draft belongs to |
| `subject` | string | Draft subject |
| `to` | array | Recipient email addresses |
| `cc` | array | CC email addresses |
| `bcc` | array | BCC email addresses |
| `text` | string | Plain text content |
| `html` | string | HTML content |
| `preview` | string | Draft preview text |
| `labels` | array | Labels assigned to the draft |
| `inReplyTo` | string | Message ID this draft replies to |
| `sendStatus` | string | Send status \(scheduled, sending, failed\) |
| `sendAt` | string | Scheduled send time |
| `createdAt` | string | Creation timestamp |
| `updatedAt` | string | Last updated timestamp |
### `agentmail_create_inbox`
Create a new email inbox with AgentMail
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentMail API key |
| `username` | string | No | Username for the inbox email address |
| `domain` | string | No | Domain for the inbox email address |
| `displayName` | string | No | Display name for the inbox |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `inboxId` | string | Unique identifier for the inbox |
| `email` | string | Email address of the inbox |
| `displayName` | string | Display name of the inbox |
| `createdAt` | string | Creation timestamp |
| `updatedAt` | string | Last updated timestamp |
### `agentmail_delete_draft`
Delete an email draft in AgentMail
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentMail API key |
| `inboxId` | string | Yes | ID of the inbox containing the draft |
| `draftId` | string | Yes | ID of the draft to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `deleted` | boolean | Whether the draft was successfully deleted |
### `agentmail_delete_inbox`
Delete an email inbox in AgentMail
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentMail API key |
| `inboxId` | string | Yes | ID of the inbox to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `deleted` | boolean | Whether the inbox was successfully deleted |
### `agentmail_delete_thread`
Delete an email thread in AgentMail (moves to trash, or permanently deletes if already in trash)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentMail API key |
| `inboxId` | string | Yes | ID of the inbox containing the thread |
| `threadId` | string | Yes | ID of the thread to delete |
| `permanent` | boolean | No | Force permanent deletion instead of moving to trash |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `deleted` | boolean | Whether the thread was successfully deleted |
### `agentmail_forward_message`
Forward an email message to new recipients in AgentMail
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentMail API key |
| `inboxId` | string | Yes | ID of the inbox containing the message |
| `messageId` | string | Yes | ID of the message to forward |
| `to` | string | Yes | Recipient email addresses \(comma-separated\) |
| `subject` | string | No | Override subject line |
| `text` | string | No | Additional plain text to prepend |
| `html` | string | No | Additional HTML to prepend |
| `cc` | string | No | CC recipient email addresses \(comma-separated\) |
| `bcc` | string | No | BCC recipient email addresses \(comma-separated\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `messageId` | string | ID of the forwarded message |
| `threadId` | string | ID of the thread |
### `agentmail_get_draft`
Get details of a specific email draft in AgentMail
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentMail API key |
| `inboxId` | string | Yes | ID of the inbox the draft belongs to |
| `draftId` | string | Yes | ID of the draft to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `draftId` | string | Unique identifier for the draft |
| `inboxId` | string | Inbox the draft belongs to |
| `subject` | string | Draft subject |
| `to` | array | Recipient email addresses |
| `cc` | array | CC email addresses |
| `bcc` | array | BCC email addresses |
| `text` | string | Plain text content |
| `html` | string | HTML content |
| `preview` | string | Draft preview text |
| `labels` | array | Labels assigned to the draft |
| `inReplyTo` | string | Message ID this draft replies to |
| `sendStatus` | string | Send status \(scheduled, sending, failed\) |
| `sendAt` | string | Scheduled send time |
| `createdAt` | string | Creation timestamp |
| `updatedAt` | string | Last updated timestamp |
### `agentmail_get_inbox`
Get details of a specific email inbox in AgentMail
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentMail API key |
| `inboxId` | string | Yes | ID of the inbox to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `inboxId` | string | Unique identifier for the inbox |
| `email` | string | Email address of the inbox |
| `displayName` | string | Display name of the inbox |
| `createdAt` | string | Creation timestamp |
| `updatedAt` | string | Last updated timestamp |
### `agentmail_get_message`
Get details of a specific email message in AgentMail
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentMail API key |
| `inboxId` | string | Yes | ID of the inbox containing the message |
| `messageId` | string | Yes | ID of the message to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `messageId` | string | Unique identifier for the message |
| `threadId` | string | ID of the thread this message belongs to |
| `from` | string | Sender email address |
| `to` | array | Recipient email addresses |
| `cc` | array | CC email addresses |
| `bcc` | array | BCC email addresses |
| `subject` | string | Message subject |
| `text` | string | Plain text content |
| `html` | string | HTML content |
| `createdAt` | string | Creation timestamp |
### `agentmail_get_thread`
Get details of a specific email thread including messages in AgentMail
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentMail API key |
| `inboxId` | string | Yes | ID of the inbox containing the thread |
| `threadId` | string | Yes | ID of the thread to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `threadId` | string | Unique identifier for the thread |
| `subject` | string | Thread subject |
| `senders` | array | List of sender email addresses |
| `recipients` | array | List of recipient email addresses |
| `messageCount` | number | Number of messages in the thread |
| `labels` | array | Labels assigned to the thread |
| `lastMessageAt` | string | Timestamp of last message |
| `createdAt` | string | Creation timestamp |
| `updatedAt` | string | Last updated timestamp |
| `messages` | array | Messages in the thread |
| ↳ `messageId` | string | Unique identifier for the message |
| ↳ `from` | string | Sender email address |
| ↳ `to` | array | Recipient email addresses |
| ↳ `cc` | array | CC email addresses |
| ↳ `bcc` | array | BCC email addresses |
| ↳ `subject` | string | Message subject |
| ↳ `text` | string | Plain text content |
| ↳ `html` | string | HTML content |
| ↳ `createdAt` | string | Creation timestamp |
### `agentmail_list_drafts`
List email drafts in an inbox in AgentMail
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentMail API key |
| `inboxId` | string | Yes | ID of the inbox to list drafts from |
| `limit` | number | No | Maximum number of drafts to return |
| `pageToken` | string | No | Pagination token for next page of results |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `drafts` | array | List of drafts |
| ↳ `draftId` | string | Unique identifier for the draft |
| ↳ `inboxId` | string | Inbox the draft belongs to |
| ↳ `subject` | string | Draft subject |
| ↳ `to` | array | Recipient email addresses |
| ↳ `cc` | array | CC email addresses |
| ↳ `bcc` | array | BCC email addresses |
| ↳ `preview` | string | Draft preview text |
| ↳ `sendStatus` | string | Send status \(scheduled, sending, failed\) |
| ↳ `sendAt` | string | Scheduled send time |
| ↳ `createdAt` | string | Creation timestamp |
| ↳ `updatedAt` | string | Last updated timestamp |
| `count` | number | Total number of drafts |
| `nextPageToken` | string | Token for retrieving the next page |
### `agentmail_list_inboxes`
List all email inboxes in AgentMail
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentMail API key |
| `limit` | number | No | Maximum number of inboxes to return |
| `pageToken` | string | No | Pagination token for next page of results |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `inboxes` | array | List of inboxes |
| ↳ `inboxId` | string | Unique identifier for the inbox |
| ↳ `email` | string | Email address of the inbox |
| ↳ `displayName` | string | Display name of the inbox |
| ↳ `createdAt` | string | Creation timestamp |
| ↳ `updatedAt` | string | Last updated timestamp |
| `count` | number | Total number of inboxes |
| `nextPageToken` | string | Token for retrieving the next page |
### `agentmail_list_messages`
List messages in an inbox in AgentMail
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentMail API key |
| `inboxId` | string | Yes | ID of the inbox to list messages from |
| `limit` | number | No | Maximum number of messages to return |
| `pageToken` | string | No | Pagination token for next page of results |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `messages` | array | List of messages in the inbox |
| ↳ `messageId` | string | Unique identifier for the message |
| ↳ `from` | string | Sender email address |
| ↳ `to` | array | Recipient email addresses |
| ↳ `subject` | string | Message subject |
| ↳ `preview` | string | Message preview text |
| ↳ `createdAt` | string | Creation timestamp |
| `count` | number | Total number of messages |
| `nextPageToken` | string | Token for retrieving the next page |
### `agentmail_list_threads`
List email threads in AgentMail
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentMail API key |
| `inboxId` | string | Yes | ID of the inbox to list threads from |
| `limit` | number | No | Maximum number of threads to return |
| `pageToken` | string | No | Pagination token for next page of results |
| `labels` | string | No | Comma-separated labels to filter threads by |
| `before` | string | No | Filter threads before this ISO 8601 timestamp |
| `after` | string | No | Filter threads after this ISO 8601 timestamp |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `threads` | array | List of email threads |
| ↳ `threadId` | string | Unique identifier for the thread |
| ↳ `subject` | string | Thread subject |
| ↳ `senders` | array | List of sender email addresses |
| ↳ `recipients` | array | List of recipient email addresses |
| ↳ `messageCount` | number | Number of messages in the thread |
| ↳ `lastMessageAt` | string | Timestamp of last message |
| ↳ `createdAt` | string | Creation timestamp |
| ↳ `updatedAt` | string | Last updated timestamp |
| `count` | number | Total number of threads |
| `nextPageToken` | string | Token for retrieving the next page |
### `agentmail_reply_message`
Reply to an existing email message in AgentMail
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentMail API key |
| `inboxId` | string | Yes | ID of the inbox to reply from |
| `messageId` | string | Yes | ID of the message to reply to |
| `text` | string | No | Plain text reply body |
| `html` | string | No | HTML reply body |
| `to` | string | No | Override recipient email addresses \(comma-separated\) |
| `cc` | string | No | CC email addresses \(comma-separated\) |
| `bcc` | string | No | BCC email addresses \(comma-separated\) |
| `replyAll` | boolean | No | Reply to all recipients of the original message |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `messageId` | string | ID of the sent reply message |
| `threadId` | string | ID of the thread |
### `agentmail_send_draft`
Send an existing email draft in AgentMail
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentMail API key |
| `inboxId` | string | Yes | ID of the inbox containing the draft |
| `draftId` | string | Yes | ID of the draft to send |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `messageId` | string | ID of the sent message |
| `threadId` | string | ID of the thread |
### `agentmail_send_message`
Send an email message from an AgentMail inbox
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentMail API key |
| `inboxId` | string | Yes | ID of the inbox to send from |
| `to` | string | Yes | Recipient email address \(comma-separated for multiple\) |
| `subject` | string | Yes | Email subject line |
| `text` | string | No | Plain text email body |
| `html` | string | No | HTML email body |
| `cc` | string | No | CC recipient email addresses \(comma-separated\) |
| `bcc` | string | No | BCC recipient email addresses \(comma-separated\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `threadId` | string | ID of the created thread |
| `messageId` | string | ID of the sent message |
| `subject` | string | Email subject line |
| `to` | string | Recipient email address |
### `agentmail_update_draft`
Update an existing email draft in AgentMail
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentMail API key |
| `inboxId` | string | Yes | ID of the inbox containing the draft |
| `draftId` | string | Yes | ID of the draft to update |
| `to` | string | No | Recipient email addresses \(comma-separated\) |
| `subject` | string | No | Draft subject line |
| `text` | string | No | Plain text draft body |
| `html` | string | No | HTML draft body |
| `cc` | string | No | CC recipient email addresses \(comma-separated\) |
| `bcc` | string | No | BCC recipient email addresses \(comma-separated\) |
| `sendAt` | string | No | ISO 8601 timestamp to schedule sending |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `draftId` | string | Unique identifier for the draft |
| `inboxId` | string | Inbox the draft belongs to |
| `subject` | string | Draft subject |
| `to` | array | Recipient email addresses |
| `cc` | array | CC email addresses |
| `bcc` | array | BCC email addresses |
| `text` | string | Plain text content |
| `html` | string | HTML content |
| `preview` | string | Draft preview text |
| `labels` | array | Labels assigned to the draft |
| `inReplyTo` | string | Message ID this draft replies to |
| `sendStatus` | string | Send status \(scheduled, sending, failed\) |
| `sendAt` | string | Scheduled send time |
| `createdAt` | string | Creation timestamp |
| `updatedAt` | string | Last updated timestamp |
### `agentmail_update_inbox`
Update the display name of an email inbox in AgentMail
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentMail API key |
| `inboxId` | string | Yes | ID of the inbox to update |
| `displayName` | string | Yes | New display name for the inbox |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `inboxId` | string | Unique identifier for the inbox |
| `email` | string | Email address of the inbox |
| `displayName` | string | Display name of the inbox |
| `createdAt` | string | Creation timestamp |
| `updatedAt` | string | Last updated timestamp |
### `agentmail_update_message`
Add or remove labels on an email message in AgentMail
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentMail API key |
| `inboxId` | string | Yes | ID of the inbox containing the message |
| `messageId` | string | Yes | ID of the message to update |
| `addLabels` | string | No | Comma-separated labels to add to the message |
| `removeLabels` | string | No | Comma-separated labels to remove from the message |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `messageId` | string | Unique identifier for the message |
| `labels` | array | Current labels on the message |
### `agentmail_update_thread`
Add or remove labels on an email thread in AgentMail
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentMail API key |
| `inboxId` | string | Yes | ID of the inbox containing the thread |
| `threadId` | string | Yes | ID of the thread to update |
| `addLabels` | string | No | Comma-separated labels to add to the thread |
| `removeLabels` | string | No | Comma-separated labels to remove from the thread |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `threadId` | string | Unique identifier for the thread |
| `labels` | array | Current labels on the thread |

View File

@@ -359,6 +359,35 @@ List tasks in Attio, optionally filtered by record, assignee, or completion stat
| ↳ `createdAt` | string | When the task was created |
| `count` | number | Number of tasks returned |
### `attio_get_task`
Get a single task by ID from Attio
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `taskId` | string | Yes | The ID of the task to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `taskId` | string | The task ID |
| `content` | string | The task content |
| `deadlineAt` | string | The task deadline |
| `isCompleted` | boolean | Whether the task is completed |
| `linkedRecords` | array | Records linked to this task |
| ↳ `targetObjectId` | string | The linked object ID |
| ↳ `targetRecordId` | string | The linked record ID |
| `assignees` | array | Task assignees |
| ↳ `type` | string | The assignee actor type \(e.g. workspace-member\) |
| ↳ `id` | string | The assignee actor ID |
| `createdByActor` | object | The actor who created this task |
| ↳ `type` | string | The actor type \(e.g. workspace-member, api-token, system\) |
| ↳ `id` | string | The actor ID |
| `createdAt` | string | When the task was created |
### `attio_create_task`
Create a task in Attio
@@ -1012,8 +1041,8 @@ Update a webhook in Attio (target URL and/or subscriptions)
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `webhookId` | string | Yes | The webhook ID to update |
| `targetUrl` | string | Yes | HTTPS target URL for webhook delivery |
| `subscriptions` | string | Yes | JSON array of subscriptions, e.g. \[\{"event_type":"note.created"\}\] |
| `targetUrl` | string | No | HTTPS target URL for webhook delivery |
| `subscriptions` | string | No | JSON array of subscriptions, e.g. \[\{"event_type":"note.created"\}\] |
#### Output

View File

@@ -0,0 +1,39 @@
---
title: Extend
description: Parse and extract content from documents
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="extend_v2"
color="#000000"
/>
## Usage Instructions
Integrate Extend AI into the workflow. Parse and extract structured content from documents or file references.
## Tools
### `extend_parser`
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `filePath` | string | No | URL to a document to be processed |
| `file` | file | No | Document file to be processed |
| `fileUpload` | object | No | File upload data from file-upload component |
| `outputFormat` | string | No | Target output format \(markdown or spatial\). Defaults to markdown. |
| `chunking` | string | No | Chunking strategy \(page, document, or section\). Defaults to page. |
| `engine` | string | No | Parsing engine \(parse_performance or parse_light\). Defaults to parse_performance. |
| `apiKey` | string | Yes | Extend API key |
#### Output
This tool does not produce any outputs.

View File

@@ -1,6 +1,6 @@
---
title: File
description: Read and parse multiple files
description: Read and write workspace files
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
@@ -27,7 +27,7 @@ The File Parser tool is particularly useful for scenarios where your agents need
## Usage Instructions
Upload files directly or import from external URLs to get UserFile objects for use in other blocks.
Read and parse files from uploads or URLs, write new workspace files, or append content to existing files.
@@ -52,4 +52,45 @@ Parse one or more uploaded files or files from URLs (text, PDF, CSV, images, etc
| `files` | file[] | Parsed files as UserFile objects |
| `combinedContent` | string | Combined content of all parsed files |
### `file_write`
Create a new workspace file. If a file with the same name already exists, a numeric suffix is added (e.g.,
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileName` | string | Yes | File name \(e.g., "data.csv"\). If a file with this name exists, a numeric suffix is added automatically. |
| `content` | string | Yes | The text content to write to the file. |
| `contentType` | string | No | MIME type for new files \(e.g., "text/plain"\). Auto-detected from file extension if omitted. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | File ID |
| `name` | string | File name |
| `size` | number | File size in bytes |
| `url` | string | URL to access the file |
### `file_append`
Append content to an existing workspace file. The file must already exist. Content is added to the end of the file.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileName` | string | Yes | Name of an existing workspace file to append to. |
| `content` | string | Yes | The text content to append to the file. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | File ID |
| `name` | string | File name |
| `size` | number | File size in bytes |
| `url` | string | URL to access the file |

View File

@@ -0,0 +1,388 @@
---
title: LaunchDarkly
description: Manage feature flags with LaunchDarkly.
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="launchdarkly"
color="#191919"
/>
{/* MANUAL-CONTENT-START:intro */}
[LaunchDarkly](https://launchdarkly.com/) is a feature management platform that enables teams to safely deploy, control, and measure their software features at scale.
With the LaunchDarkly integration in Sim, you can:
- **Feature flag management** — List, create, update, toggle, and delete feature flags programmatically. Toggle flags on or off in specific environments using LaunchDarkly's semantic patch API.
- **Flag status monitoring** — Check whether a flag is active, inactive, new, or launched in a given environment. Track the last time a flag was evaluated.
- **Project and environment management** — List all projects and their environments to understand your LaunchDarkly organization structure.
- **User segments** — List user segments within a project and environment to understand how your audience is organized for targeting.
- **Team visibility** — List account members and their roles for auditing and access management workflows.
- **Audit log** — Retrieve recent audit log entries to track who changed what, when. Filter entries by resource type for targeted monitoring.
In Sim, the LaunchDarkly integration enables your agents to automate feature flag operations as part of their workflows. This allows for automation scenarios such as toggling flags on/off based on deployment pipeline events, monitoring flag status and alerting on stale or unused flags, auditing flag changes by querying the audit log after deployments, syncing flag metadata with your project management tools, and listing all feature flags across projects for governance.
## Authentication
This integration uses a LaunchDarkly API key. You can create personal access tokens or service tokens in the LaunchDarkly dashboard under **Account Settings > Authorization**. The API key is passed directly in the `Authorization` header (no `Bearer` prefix).
## Need Help?
If you encounter issues with the LaunchDarkly integration, contact us at [help@sim.ai](mailto:help@sim.ai)
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate LaunchDarkly into your workflow. List, create, update, toggle, and delete feature flags. Manage projects, environments, segments, members, and audit logs. Requires API Key.
## Tools
### `launchdarkly_create_flag`
Create a new feature flag in a LaunchDarkly project.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | LaunchDarkly API key |
| `projectKey` | string | Yes | The project key to create the flag in |
| `name` | string | Yes | Human-readable name for the feature flag |
| `key` | string | Yes | Unique key for the feature flag \(used in code\) |
| `description` | string | No | Description of the feature flag |
| `tags` | string | No | Comma-separated list of tags |
| `temporary` | boolean | No | Whether the flag is temporary \(default true\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The unique key of the feature flag |
| `name` | string | The human-readable name of the feature flag |
| `kind` | string | The type of flag \(boolean or multivariate\) |
| `description` | string | Description of the feature flag |
| `temporary` | boolean | Whether the flag is temporary |
| `archived` | boolean | Whether the flag is archived |
| `deprecated` | boolean | Whether the flag is deprecated |
| `creationDate` | number | Unix timestamp in milliseconds when the flag was created |
| `tags` | array | Tags applied to the flag |
| `variations` | array | The variations for this feature flag |
| ↳ `value` | string | The variation value |
| ↳ `name` | string | The variation name |
| ↳ `description` | string | The variation description |
| `maintainerId` | string | The ID of the member who maintains this flag |
### `launchdarkly_delete_flag`
Delete a feature flag from a LaunchDarkly project.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | LaunchDarkly API key |
| `projectKey` | string | Yes | The project key |
| `flagKey` | string | Yes | The feature flag key to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `deleted` | boolean | Whether the flag was successfully deleted |
### `launchdarkly_get_audit_log`
List audit log entries from your LaunchDarkly account.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | LaunchDarkly API key |
| `limit` | number | No | Maximum number of entries to return \(default 10, max 20\) |
| `spec` | string | No | Filter expression \(e.g., "resourceType:flag"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `entries` | array | List of audit log entries |
| ↳ `id` | string | The audit log entry ID |
| ↳ `date` | number | Unix timestamp in milliseconds |
| ↳ `kind` | string | The type of action performed |
| ↳ `name` | string | The name of the resource acted on |
| ↳ `description` | string | Full description of the action |
| ↳ `shortDescription` | string | Short description of the action |
| ↳ `memberEmail` | string | Email of the member who performed the action |
| ↳ `targetName` | string | Name of the target resource |
| ↳ `targetKind` | string | Kind of the target resource |
| `totalCount` | number | Total number of audit log entries |
### `launchdarkly_get_flag`
Get a single feature flag by key from a LaunchDarkly project.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | LaunchDarkly API key |
| `projectKey` | string | Yes | The project key |
| `flagKey` | string | Yes | The feature flag key |
| `environmentKey` | string | No | Filter flag configuration to a specific environment |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The unique key of the feature flag |
| `name` | string | The human-readable name of the feature flag |
| `kind` | string | The type of flag \(boolean or multivariate\) |
| `description` | string | Description of the feature flag |
| `temporary` | boolean | Whether the flag is temporary |
| `archived` | boolean | Whether the flag is archived |
| `deprecated` | boolean | Whether the flag is deprecated |
| `creationDate` | number | Unix timestamp in milliseconds when the flag was created |
| `tags` | array | Tags applied to the flag |
| `variations` | array | The variations for this feature flag |
| ↳ `value` | string | The variation value |
| ↳ `name` | string | The variation name |
| ↳ `description` | string | The variation description |
| `maintainerId` | string | The ID of the member who maintains this flag |
| `on` | boolean | Whether the flag is on in the requested environment \(null if no single environment was specified\) |
### `launchdarkly_get_flag_status`
Get the status of a feature flag across environments (active, inactive, launched, etc.).
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | LaunchDarkly API key |
| `projectKey` | string | Yes | The project key |
| `flagKey` | string | Yes | The feature flag key |
| `environmentKey` | string | Yes | The environment key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `name` | string | The flag status \(new, active, inactive, launched\) |
| `lastRequested` | string | Timestamp of the last evaluation |
| `defaultVal` | string | The default variation value |
### `launchdarkly_list_environments`
List environments in a LaunchDarkly project.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | LaunchDarkly API key |
| `projectKey` | string | Yes | The project key to list environments for |
| `limit` | number | No | Maximum number of environments to return \(default 20\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `environments` | array | List of environments |
| ↳ `id` | string | The environment ID |
| ↳ `key` | string | The unique environment key |
| ↳ `name` | string | The environment name |
| ↳ `color` | string | The color assigned to this environment |
| ↳ `apiKey` | string | The server-side SDK key for this environment |
| ↳ `mobileKey` | string | The mobile SDK key for this environment |
| ↳ `tags` | array | Tags applied to the environment |
| `totalCount` | number | Total number of environments |
### `launchdarkly_list_flags`
List feature flags in a LaunchDarkly project.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | LaunchDarkly API key |
| `projectKey` | string | Yes | The project key to list flags for |
| `environmentKey` | string | No | Filter flag configurations to a specific environment |
| `tag` | string | No | Filter flags by tag name |
| `limit` | number | No | Maximum number of flags to return \(default 20\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `flags` | array | List of feature flags |
| ↳ `key` | string | The unique key of the feature flag |
| ↳ `name` | string | The human-readable name of the feature flag |
| ↳ `kind` | string | The type of flag \(boolean or multivariate\) |
| ↳ `description` | string | Description of the feature flag |
| ↳ `temporary` | boolean | Whether the flag is temporary |
| ↳ `archived` | boolean | Whether the flag is archived |
| ↳ `deprecated` | boolean | Whether the flag is deprecated |
| ↳ `creationDate` | number | Unix timestamp in milliseconds when the flag was created |
| ↳ `tags` | array | Tags applied to the flag |
| ↳ `variations` | array | The variations for this feature flag |
| ↳ `value` | string | The variation value |
| ↳ `name` | string | The variation name |
| ↳ `description` | string | The variation description |
| ↳ `maintainerId` | string | The ID of the member who maintains this flag |
| `totalCount` | number | Total number of flags |
### `launchdarkly_list_members`
List account members in your LaunchDarkly organization.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | LaunchDarkly API key |
| `limit` | number | No | Maximum number of members to return \(default 20\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `members` | array | List of account members |
| ↳ `id` | string | The member ID |
| ↳ `email` | string | The member email address |
| ↳ `firstName` | string | The member first name |
| ↳ `lastName` | string | The member last name |
| ↳ `role` | string | The member role \(reader, writer, admin, owner\) |
| ↳ `lastSeen` | number | Unix timestamp of last activity |
| ↳ `creationDate` | number | Unix timestamp when the member was created |
| ↳ `verified` | boolean | Whether the member email is verified |
| `totalCount` | number | Total number of members |
### `launchdarkly_list_projects`
List all projects in your LaunchDarkly account.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | LaunchDarkly API key |
| `limit` | number | No | Maximum number of projects to return \(default 20\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `projects` | array | List of projects |
| ↳ `id` | string | The project ID |
| ↳ `key` | string | The unique project key |
| ↳ `name` | string | The project name |
| ↳ `tags` | array | Tags applied to the project |
| `totalCount` | number | Total number of projects |
### `launchdarkly_list_segments`
List user segments in a LaunchDarkly project and environment.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | LaunchDarkly API key |
| `projectKey` | string | Yes | The project key |
| `environmentKey` | string | Yes | The environment key |
| `limit` | number | No | Maximum number of segments to return \(default 20\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `segments` | array | List of user segments |
| ↳ `key` | string | The unique segment key |
| ↳ `name` | string | The segment name |
| ↳ `description` | string | The segment description |
| ↳ `tags` | array | Tags applied to the segment |
| ↳ `creationDate` | number | Unix timestamp in milliseconds when the segment was created |
| ↳ `unbounded` | boolean | Whether this is an unbounded \(big\) segment |
| ↳ `included` | array | User keys explicitly included in the segment |
| ↳ `excluded` | array | User keys explicitly excluded from the segment |
| `totalCount` | number | Total number of segments |
### `launchdarkly_toggle_flag`
Toggle a feature flag on or off in a specific LaunchDarkly environment.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | LaunchDarkly API key |
| `projectKey` | string | Yes | The project key |
| `flagKey` | string | Yes | The feature flag key to toggle |
| `environmentKey` | string | Yes | The environment key to toggle the flag in |
| `enabled` | boolean | Yes | Whether to turn the flag on \(true\) or off \(false\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The unique key of the feature flag |
| `name` | string | The human-readable name of the feature flag |
| `kind` | string | The type of flag \(boolean or multivariate\) |
| `description` | string | Description of the feature flag |
| `temporary` | boolean | Whether the flag is temporary |
| `archived` | boolean | Whether the flag is archived |
| `deprecated` | boolean | Whether the flag is deprecated |
| `creationDate` | number | Unix timestamp in milliseconds when the flag was created |
| `tags` | array | Tags applied to the flag |
| `variations` | array | The variations for this feature flag |
| ↳ `value` | string | The variation value |
| ↳ `name` | string | The variation name |
| ↳ `description` | string | The variation description |
| `maintainerId` | string | The ID of the member who maintains this flag |
| `on` | boolean | Whether the flag is now on in the target environment |
### `launchdarkly_update_flag`
Update a feature flag metadata (name, description, tags, temporary, archived) using semantic patch.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | LaunchDarkly API key |
| `projectKey` | string | Yes | The project key |
| `flagKey` | string | Yes | The feature flag key to update |
| `updateName` | string | No | New name for the flag |
| `updateDescription` | string | No | New description for the flag |
| `addTags` | string | No | Comma-separated tags to add |
| `removeTags` | string | No | Comma-separated tags to remove |
| `archive` | boolean | No | Set to true to archive, false to restore |
| `comment` | string | No | Optional comment explaining the update |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `key` | string | The unique key of the feature flag |
| `name` | string | The human-readable name of the feature flag |
| `kind` | string | The type of flag \(boolean or multivariate\) |
| `description` | string | Description of the feature flag |
| `temporary` | boolean | Whether the flag is temporary |
| `archived` | boolean | Whether the flag is archived |
| `deprecated` | boolean | Whether the flag is deprecated |
| `creationDate` | number | Unix timestamp in milliseconds when the flag was created |
| `tags` | array | Tags applied to the flag |
| `variations` | array | The variations for this feature flag |
| ↳ `value` | string | The variation value |
| ↳ `name` | string | The variation name |
| ↳ `description` | string | The variation description |
| `maintainerId` | string | The ID of the member who maintains this flag |

View File

@@ -2,6 +2,7 @@
"pages": [
"index",
"a2a",
"agentmail",
"ahrefs",
"airtable",
"airweave",
@@ -39,6 +40,7 @@
"enrich",
"evernote",
"exa",
"extend",
"fathom",
"file",
"firecrawl",
@@ -87,6 +89,7 @@
"ketch",
"knowledge",
"langsmith",
"launchdarkly",
"lemlist",
"linear",
"linkedin",
@@ -121,6 +124,7 @@
"polymarket",
"postgresql",
"posthog",
"profound",
"pulse",
"qdrant",
"quiver",
@@ -131,9 +135,11 @@
"resend",
"revenuecat",
"rippling",
"rootly",
"s3",
"salesforce",
"search",
"secrets_manager",
"sendgrid",
"sentry",
"serper",
@@ -151,6 +157,7 @@
"stt",
"supabase",
"table",
"tailscale",
"tavily",
"telegram",
"textract",

View File

@@ -0,0 +1,626 @@
---
title: Profound
description: AI visibility and analytics with Profound
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="profound"
color="#000000"
/>
{/* MANUAL-CONTENT-START:intro */}
[Profound](https://tryprofound.com/) is an AI visibility and analytics platform that helps brands understand how they appear across AI-powered search engines, chatbots, and assistants. It tracks mentions, citations, sentiment, bot traffic, and referral patterns across platforms like ChatGPT, Perplexity, Google AI Overviews, and more.
With the Profound integration in Sim, you can:
- **Monitor AI Visibility**: Track share of voice, visibility scores, and mention counts across AI platforms for your brand and competitors.
- **Analyze Sentiment**: Measure how positively or negatively your brand is discussed in AI-generated responses.
- **Track Citations**: See which URLs are being cited by AI models and your citation share relative to competitors.
- **Monitor Bot Traffic**: Analyze AI crawler activity on your domain, including GPTBot, ClaudeBot, and other AI agents, with hourly granularity.
- **Track Referral Traffic**: Monitor human visits arriving from AI platforms to your website.
- **Explore Prompt Data**: Access raw prompt-answer pairs, query fanouts, and prompt volume trends across AI platforms.
- **Optimize Content**: Get AEO (Answer Engine Optimization) scores and actionable recommendations to improve how AI models reference your content.
- **Manage Categories & Assets**: List and explore your tracked categories, assets (brands), topics, tags, personas, and regions.
These tools let your agents automate AI visibility monitoring, competitive intelligence, and content optimization workflows. To use the Profound integration, you'll need a Profound account with API access.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Track how your brand appears across AI platforms. Monitor visibility scores, sentiment, citations, bot traffic, referrals, content optimization, and prompt volumes with Profound.
## Tools
### `profound_list_categories`
List all organization categories in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `categories` | json | List of organization categories |
| ↳ `id` | string | Category ID |
| ↳ `name` | string | Category name |
### `profound_list_regions`
List all organization regions in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `regions` | json | List of organization regions |
| ↳ `id` | string | Region ID \(UUID\) |
| ↳ `name` | string | Region name |
### `profound_list_models`
List all AI models/platforms tracked in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `models` | json | List of AI models/platforms |
| ↳ `id` | string | Model ID \(UUID\) |
| ↳ `name` | string | Model/platform name |
### `profound_list_domains`
List all organization domains in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `domains` | json | List of organization domains |
| ↳ `id` | string | Domain ID \(UUID\) |
| ↳ `name` | string | Domain name |
| ↳ `createdAt` | string | When the domain was added |
### `profound_list_assets`
List all organization assets (companies/brands) across all categories in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `assets` | json | List of organization assets with category info |
| ↳ `id` | string | Asset ID |
| ↳ `name` | string | Asset/company name |
| ↳ `website` | string | Asset website URL |
| ↳ `alternateDomains` | json | Alternate domain names |
| ↳ `isOwned` | boolean | Whether this asset is owned by the organization |
| ↳ `createdAt` | string | When the asset was created |
| ↳ `logoUrl` | string | URL of the asset logo |
| ↳ `categoryId` | string | Category ID the asset belongs to |
| ↳ `categoryName` | string | Category name |
### `profound_list_personas`
List all organization personas across all categories in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `personas` | json | List of organization personas with profile details |
| ↳ `id` | string | Persona ID |
| ↳ `name` | string | Persona name |
| ↳ `categoryId` | string | Category ID |
| ↳ `categoryName` | string | Category name |
| ↳ `persona` | json | Persona profile with behavior, employment, and demographics |
### `profound_category_topics`
List topics for a specific category in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `categoryId` | string | Yes | Category ID \(UUID\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `topics` | json | List of topics in the category |
| ↳ `id` | string | Topic ID \(UUID\) |
| ↳ `name` | string | Topic name |
### `profound_category_tags`
List tags for a specific category in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `categoryId` | string | Yes | Category ID \(UUID\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tags` | json | List of tags in the category |
| ↳ `id` | string | Tag ID \(UUID\) |
| ↳ `name` | string | Tag name |
### `profound_category_prompts`
List prompts for a specific category in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `categoryId` | string | Yes | Category ID \(UUID\) |
| `limit` | number | No | Maximum number of results \(default 10000, max 10000\) |
| `cursor` | string | No | Pagination cursor from previous response |
| `orderDir` | string | No | Sort direction: asc or desc \(default desc\) |
| `promptType` | string | No | Comma-separated prompt types to filter: visibility, sentiment |
| `topicId` | string | No | Comma-separated topic IDs \(UUIDs\) to filter by |
| `tagId` | string | No | Comma-separated tag IDs \(UUIDs\) to filter by |
| `regionId` | string | No | Comma-separated region IDs \(UUIDs\) to filter by |
| `platformId` | string | No | Comma-separated platform IDs \(UUIDs\) to filter by |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of prompts |
| `nextCursor` | string | Cursor for next page of results |
| `prompts` | json | List of prompts |
| ↳ `id` | string | Prompt ID |
| ↳ `prompt` | string | Prompt text |
| ↳ `promptType` | string | Prompt type \(visibility or sentiment\) |
| ↳ `topicId` | string | Topic ID |
| ↳ `topicName` | string | Topic name |
| ↳ `tags` | json | Associated tags |
| ↳ `regions` | json | Associated regions |
| ↳ `platforms` | json | Associated platforms |
| ↳ `createdAt` | string | When the prompt was created |
### `profound_category_assets`
List assets (companies/brands) for a specific category in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `categoryId` | string | Yes | Category ID \(UUID\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `assets` | json | List of assets in the category |
| ↳ `id` | string | Asset ID |
| ↳ `name` | string | Asset/company name |
| ↳ `website` | string | Website URL |
| ↳ `alternateDomains` | json | Alternate domain names |
| ↳ `isOwned` | boolean | Whether the asset is owned by the organization |
| ↳ `createdAt` | string | When the asset was created |
| ↳ `logoUrl` | string | URL of the asset logo |
### `profound_category_personas`
List personas for a specific category in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `categoryId` | string | Yes | Category ID \(UUID\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `personas` | json | List of personas in the category |
| ↳ `id` | string | Persona ID |
| ↳ `name` | string | Persona name |
| ↳ `persona` | json | Persona profile with behavior, employment, and demographics |
### `profound_visibility_report`
Query AI visibility report for a category in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `categoryId` | string | Yes | Category ID \(UUID\) |
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
| `endDate` | string | Yes | End date \(YYYY-MM-DD or ISO 8601\) |
| `metrics` | string | Yes | Comma-separated metrics: share_of_voice, mentions_count, visibility_score, executions, average_position |
| `dimensions` | string | No | Comma-separated dimensions: date, region, topic, model, asset_name, prompt, tag, persona |
| `dateInterval` | string | No | Date interval: hour, day, week, month, year |
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"asset_name","operator":"is","value":"Company"\}\] |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of rows in the report |
| `data` | json | Report data rows with metrics and dimension values |
| ↳ `metrics` | json | Array of metric values matching requested metrics order |
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
### `profound_sentiment_report`
Query sentiment report for a category in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `categoryId` | string | Yes | Category ID \(UUID\) |
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
| `endDate` | string | Yes | End date \(YYYY-MM-DD or ISO 8601\) |
| `metrics` | string | Yes | Comma-separated metrics: positive, negative, occurrences |
| `dimensions` | string | No | Comma-separated dimensions: theme, date, region, topic, model, asset_name, tag, prompt, sentiment_type, persona |
| `dateInterval` | string | No | Date interval: hour, day, week, month, year |
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"asset_name","operator":"is","value":"Company"\}\] |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of rows in the report |
| `data` | json | Report data rows with metrics and dimension values |
| ↳ `metrics` | json | Array of metric values matching requested metrics order |
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
### `profound_citations_report`
Query citations report for a category in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `categoryId` | string | Yes | Category ID \(UUID\) |
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
| `endDate` | string | Yes | End date \(YYYY-MM-DD or ISO 8601\) |
| `metrics` | string | Yes | Comma-separated metrics: count, citation_share |
| `dimensions` | string | No | Comma-separated dimensions: hostname, path, date, region, topic, model, tag, prompt, url, root_domain, persona, citation_category |
| `dateInterval` | string | No | Date interval: hour, day, week, month, year |
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"hostname","operator":"is","value":"example.com"\}\] |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of rows in the report |
| `data` | json | Report data rows with metrics and dimension values |
| ↳ `metrics` | json | Array of metric values matching requested metrics order |
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
### `profound_query_fanouts`
Query fanout report showing how AI models expand prompts into sub-queries in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `categoryId` | string | Yes | Category ID \(UUID\) |
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
| `endDate` | string | Yes | End date \(YYYY-MM-DD or ISO 8601\) |
| `metrics` | string | Yes | Comma-separated metrics: fanouts_per_execution, total_fanouts, share |
| `dimensions` | string | No | Comma-separated dimensions: prompt, query, model, region, date |
| `dateInterval` | string | No | Date interval: hour, day, week, month, year |
| `filters` | string | No | JSON array of filter objects |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of rows in the report |
| `data` | json | Report data rows with metrics and dimension values |
| ↳ `metrics` | json | Array of metric values matching requested metrics order |
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
### `profound_prompt_answers`
Get raw prompt answers data for a category in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `categoryId` | string | Yes | Category ID \(UUID\) |
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
| `endDate` | string | Yes | End date \(YYYY-MM-DD or ISO 8601\) |
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"prompt_type","operator":"is","value":"visibility"\}\] |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of answer rows |
| `data` | json | Raw prompt answer data |
| ↳ `prompt` | string | The prompt text |
| ↳ `promptType` | string | Prompt type \(visibility or sentiment\) |
| ↳ `response` | string | AI model response text |
| ↳ `mentions` | json | Companies/assets mentioned in the response |
| ↳ `citations` | json | URLs cited in the response |
| ↳ `topic` | string | Topic name |
| ↳ `region` | string | Region name |
| ↳ `model` | string | AI model/platform name |
| ↳ `asset` | string | Asset name |
| ↳ `createdAt` | string | Timestamp when the answer was collected |
### `profound_bots_report`
Query bot traffic report with hourly granularity for a domain in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `domain` | string | Yes | Domain to query bot traffic for \(e.g. example.com\) |
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
| `endDate` | string | No | End date \(YYYY-MM-DD or ISO 8601\). Defaults to now |
| `metrics` | string | Yes | Comma-separated metrics: count, citations, indexing, training, last_visit |
| `dimensions` | string | No | Comma-separated dimensions: date, hour, path, bot_name, bot_provider, bot_type |
| `dateInterval` | string | No | Date interval: hour, day, week, month, year |
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"bot_name","operator":"is","value":"GPTBot"\}\] |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of rows in the report |
| `data` | json | Report data rows with metrics and dimension values |
| ↳ `metrics` | json | Array of metric values matching requested metrics order |
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
### `profound_referrals_report`
Query human referral traffic report with hourly granularity for a domain in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `domain` | string | Yes | Domain to query referral traffic for \(e.g. example.com\) |
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
| `endDate` | string | No | End date \(YYYY-MM-DD or ISO 8601\). Defaults to now |
| `metrics` | string | Yes | Comma-separated metrics: visits, last_visit |
| `dimensions` | string | No | Comma-separated dimensions: date, hour, path, referral_source, referral_type |
| `dateInterval` | string | No | Date interval: hour, day, week, month, year |
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"referral_source","operator":"is","value":"openai"\}\] |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of rows in the report |
| `data` | json | Report data rows with metrics and dimension values |
| ↳ `metrics` | json | Array of metric values matching requested metrics order |
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
### `profound_raw_logs`
Get raw traffic logs with filters for a domain in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `domain` | string | Yes | Domain to query logs for \(e.g. example.com\) |
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
| `endDate` | string | No | End date \(YYYY-MM-DD or ISO 8601\). Defaults to now |
| `dimensions` | string | No | Comma-separated dimensions: timestamp, method, host, path, status_code, ip, user_agent, referer, bytes_sent, duration_ms, query_params |
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"path","operator":"contains","value":"/blog"\}\] |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of log entries |
| `data` | json | Log data rows with metrics and dimension values |
| ↳ `metrics` | json | Array of metric values \(count\) |
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
### `profound_bot_logs`
Get identified bot visit logs with filters for a domain in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `domain` | string | Yes | Domain to query bot logs for \(e.g. example.com\) |
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
| `endDate` | string | No | End date \(YYYY-MM-DD or ISO 8601\). Defaults to now |
| `dimensions` | string | No | Comma-separated dimensions: timestamp, method, host, path, status_code, ip, user_agent, referer, bytes_sent, duration_ms, query_params, bot_name, bot_provider, bot_types |
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"bot_name","operator":"is","value":"GPTBot"\}\] |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of bot log entries |
| `data` | json | Bot log data rows with metrics and dimension values |
| ↳ `metrics` | json | Array of metric values \(count\) |
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
### `profound_list_optimizations`
List content optimization entries for an asset in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `assetId` | string | Yes | Asset ID \(UUID\) |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
| `offset` | number | No | Offset for pagination \(default 0\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of optimization entries |
| `optimizations` | json | List of content optimization entries |
| ↳ `id` | string | Optimization ID \(UUID\) |
| ↳ `title` | string | Content title |
| ↳ `createdAt` | string | When the optimization was created |
| ↳ `extractedInput` | string | Extracted input text |
| ↳ `type` | string | Content type: file, text, or url |
| ↳ `status` | string | Optimization status |
### `profound_optimization_analysis`
Get detailed content optimization analysis for a specific content item in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `assetId` | string | Yes | Asset ID \(UUID\) |
| `contentId` | string | Yes | Content/optimization ID \(UUID\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `content` | json | The analyzed content |
| ↳ `format` | string | Content format: markdown or html |
| ↳ `value` | string | Content text |
| `aeoContentScore` | json | AEO content score with target zone |
| ↳ `value` | number | AEO score value |
| ↳ `targetZone` | json | Target zone range |
| ↳ `low` | number | Low end of target range |
| ↳ `high` | number | High end of target range |
| `analysis` | json | Analysis breakdown by category |
| ↳ `breakdown` | json | Array of scoring breakdowns |
| ↳ `title` | string | Category title |
| ↳ `weight` | number | Category weight |
| ↳ `score` | number | Category score |
| `recommendations` | json | Content optimization recommendations |
| ↳ `title` | string | Recommendation title |
| ↳ `status` | string | Status: done or pending |
| ↳ `impact` | json | Impact details with section and score |
| ↳ `suggestion` | json | Suggestion text and rationale |
| ↳ `text` | string | Suggestion text |
| ↳ `rationale` | string | Why this recommendation matters |
### `profound_prompt_volume`
Query prompt volume data to understand search demand across AI platforms in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
| `endDate` | string | Yes | End date \(YYYY-MM-DD or ISO 8601\) |
| `metrics` | string | Yes | Comma-separated metrics: volume, change |
| `dimensions` | string | No | Comma-separated dimensions: keyword, date, platform, country_code, matching_type, frequency |
| `dateInterval` | string | No | Date interval: hour, day, week, month, year |
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"keyword","operator":"contains","value":"best"\}\] |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of rows in the report |
| `data` | json | Volume data rows with metrics and dimension values |
| ↳ `metrics` | json | Array of metric values matching requested metrics order |
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
### `profound_citation_prompts`
Get prompts that cite a specific domain across AI platforms in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `inputDomain` | string | Yes | Domain to look up citations for \(e.g. ramp.com\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `data` | json | Citation prompt data for the queried domain |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,891 @@
---
title: Rootly
description: Manage incidents, alerts, and on-call with Rootly
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="rootly"
color="#6C72C8"
/>
{/* MANUAL-CONTENT-START:intro */}
[Rootly](https://rootly.com/) is an incident management platform that helps teams respond to, mitigate, and learn from incidents — all without leaving Slack or your existing tools. Rootly automates on-call alerting, incident workflows, status page updates, and retrospectives so engineering teams can resolve issues faster and reduce toil.
**Why Rootly?**
- **End-to-End Incident Management:** Create, track, update, and resolve incidents with full lifecycle support — from initial triage through retrospective.
- **On-Call Alerting:** Create and manage alerts with deduplication, routing, and escalation to ensure the right people are notified immediately.
- **Timeline Events:** Add structured timeline events to incidents for clear, auditable incident narratives.
- **Service Catalog:** Maintain a catalog of services and map them to incidents for precise impact tracking.
- **Severity & Prioritization:** Use configurable severity levels to prioritize incidents and drive appropriate response urgency.
- **Retrospectives:** Access post-incident retrospectives to identify root causes, capture learnings, and drive reliability improvements.
**Using Rootly in Sim**
Sim's Rootly integration connects your agentic workflows directly to your Rootly account using an API key. With operations spanning incidents, alerts, services, severities, teams, environments, functionalities, incident types, and retrospectives, you can build powerful incident management automations without writing backend code.
**Key benefits of using Rootly in Sim:**
- **Automated incident creation:** Trigger incident creation from monitoring alerts, customer reports, or anomaly detection workflows with full metadata including severity, services, and teams.
- **Incident lifecycle automation:** Automatically update incident status, add timeline events, and attach mitigation or resolution messages as your response progresses.
- **Alert management:** Create and list alerts with deduplication support to integrate Rootly into your existing monitoring and notification pipelines.
- **Organizational awareness:** Query services, severities, teams, environments, functionalities, and incident types to build context-aware incident workflows.
- **Retrospective insights:** List and filter retrospectives to feed post-incident learnings into continuous improvement workflows.
Whether you're automating incident response, building on-call alerting pipelines, or driving post-incident learning, Rootly in Sim gives you direct, secure access to the Rootly API — no middleware required. Simply configure your API key, select the operation you need, and let Sim handle the rest.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate Rootly incident management into workflows. Create and manage incidents, alerts, services, severities, and retrospectives.
## Tools
### `rootly_create_incident`
Create a new incident in Rootly with optional severity, services, and teams.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `title` | string | No | The title of the incident \(auto-generated if not provided\) |
| `summary` | string | No | A summary of the incident |
| `severityId` | string | No | Severity ID to attach to the incident |
| `status` | string | No | Incident status \(in_triage, started, detected, acknowledged, mitigated, resolved, closed, cancelled, scheduled, in_progress, completed\) |
| `kind` | string | No | Incident kind \(normal, normal_sub, test, test_sub, example, example_sub, backfilled, scheduled, scheduled_sub\) |
| `serviceIds` | string | No | Comma-separated service IDs to attach |
| `environmentIds` | string | No | Comma-separated environment IDs to attach |
| `groupIds` | string | No | Comma-separated team/group IDs to attach |
| `incidentTypeIds` | string | No | Comma-separated incident type IDs to attach |
| `functionalityIds` | string | No | Comma-separated functionality IDs to attach |
| `labels` | string | No | Labels as JSON object, e.g. \{"platform":"osx","version":"1.29"\} |
| `private` | boolean | No | Create as a private incident \(cannot be undone\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `incident` | object | The created incident |
| ↳ `id` | string | Unique incident ID |
| ↳ `sequentialId` | number | Sequential incident number |
| ↳ `title` | string | Incident title |
| ↳ `slug` | string | Incident slug |
| ↳ `kind` | string | Incident kind |
| ↳ `summary` | string | Incident summary |
| ↳ `status` | string | Incident status |
| ↳ `private` | boolean | Whether the incident is private |
| ↳ `url` | string | URL to the incident |
| ↳ `shortUrl` | string | Short URL to the incident |
| ↳ `severityName` | string | Severity name |
| ↳ `severityId` | string | Severity ID |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| ↳ `startedAt` | string | Start date |
| ↳ `mitigatedAt` | string | Mitigation date |
| ↳ `resolvedAt` | string | Resolution date |
| ↳ `closedAt` | string | Closed date |
### `rootly_get_incident`
Retrieve a single incident by ID from Rootly.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `incidentId` | string | Yes | The ID of the incident to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `incident` | object | The incident details |
| ↳ `id` | string | Unique incident ID |
| ↳ `sequentialId` | number | Sequential incident number |
| ↳ `title` | string | Incident title |
| ↳ `slug` | string | Incident slug |
| ↳ `kind` | string | Incident kind |
| ↳ `summary` | string | Incident summary |
| ↳ `status` | string | Incident status |
| ↳ `private` | boolean | Whether the incident is private |
| ↳ `url` | string | URL to the incident |
| ↳ `shortUrl` | string | Short URL to the incident |
| ↳ `severityName` | string | Severity name |
| ↳ `severityId` | string | Severity ID |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| ↳ `startedAt` | string | Start date |
| ↳ `mitigatedAt` | string | Mitigation date |
| ↳ `resolvedAt` | string | Resolution date |
| ↳ `closedAt` | string | Closed date |
### `rootly_update_incident`
Update an existing incident in Rootly (status, severity, summary, etc.).
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `incidentId` | string | Yes | The ID of the incident to update |
| `title` | string | No | Updated incident title |
| `summary` | string | No | Updated incident summary |
| `severityId` | string | No | Updated severity ID |
| `status` | string | No | Updated status \(in_triage, started, detected, acknowledged, mitigated, resolved, closed, cancelled, scheduled, in_progress, completed\) |
| `kind` | string | No | Incident kind \(normal, normal_sub, test, test_sub, example, example_sub, backfilled, scheduled, scheduled_sub\) |
| `private` | boolean | No | Set incident as private \(cannot be undone\) |
| `serviceIds` | string | No | Comma-separated service IDs |
| `environmentIds` | string | No | Comma-separated environment IDs |
| `groupIds` | string | No | Comma-separated team/group IDs |
| `incidentTypeIds` | string | No | Comma-separated incident type IDs to attach |
| `functionalityIds` | string | No | Comma-separated functionality IDs to attach |
| `labels` | string | No | Labels as JSON object, e.g. \{"platform":"osx","version":"1.29"\} |
| `mitigationMessage` | string | No | How was the incident mitigated? |
| `resolutionMessage` | string | No | How was the incident resolved? |
| `cancellationMessage` | string | No | Why was the incident cancelled? |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `incident` | object | The updated incident |
| ↳ `id` | string | Unique incident ID |
| ↳ `sequentialId` | number | Sequential incident number |
| ↳ `title` | string | Incident title |
| ↳ `slug` | string | Incident slug |
| ↳ `kind` | string | Incident kind |
| ↳ `summary` | string | Incident summary |
| ↳ `status` | string | Incident status |
| ↳ `private` | boolean | Whether the incident is private |
| ↳ `url` | string | URL to the incident |
| ↳ `shortUrl` | string | Short URL to the incident |
| ↳ `severityName` | string | Severity name |
| ↳ `severityId` | string | Severity ID |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| ↳ `startedAt` | string | Start date |
| ↳ `mitigatedAt` | string | Mitigation date |
| ↳ `resolvedAt` | string | Resolution date |
| ↳ `closedAt` | string | Closed date |
### `rootly_list_incidents`
List incidents from Rootly with optional filtering by status, severity, and more.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `status` | string | No | Filter by status \(in_triage, started, detected, acknowledged, mitigated, resolved, closed, cancelled, scheduled, in_progress, completed\) |
| `severity` | string | No | Filter by severity slug |
| `search` | string | No | Search term to filter incidents |
| `services` | string | No | Filter by service slugs \(comma-separated\) |
| `teams` | string | No | Filter by team slugs \(comma-separated\) |
| `environments` | string | No | Filter by environment slugs \(comma-separated\) |
| `sort` | string | No | Sort order \(e.g., -created_at, created_at, -started_at\) |
| `pageSize` | number | No | Number of items per page \(default: 20\) |
| `pageNumber` | number | No | Page number for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `incidents` | array | List of incidents |
| ↳ `id` | string | Unique incident ID |
| ↳ `sequentialId` | number | Sequential incident number |
| ↳ `title` | string | Incident title |
| ↳ `slug` | string | Incident slug |
| ↳ `kind` | string | Incident kind |
| ↳ `summary` | string | Incident summary |
| ↳ `status` | string | Incident status |
| ↳ `private` | boolean | Whether the incident is private |
| ↳ `url` | string | URL to the incident |
| ↳ `shortUrl` | string | Short URL to the incident |
| ↳ `severityName` | string | Severity name |
| ↳ `severityId` | string | Severity ID |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| ↳ `startedAt` | string | Start date |
| ↳ `mitigatedAt` | string | Mitigation date |
| ↳ `resolvedAt` | string | Resolution date |
| ↳ `closedAt` | string | Closed date |
| `totalCount` | number | Total number of incidents returned |
### `rootly_create_alert`
Create a new alert in Rootly for on-call notification and routing.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `summary` | string | Yes | The summary of the alert |
| `description` | string | No | A detailed description of the alert |
| `source` | string | Yes | The source of the alert \(e.g., api, manual, datadog, pagerduty\) |
| `status` | string | No | Alert status on creation \(open, triggered\) |
| `serviceIds` | string | No | Comma-separated service IDs to attach |
| `groupIds` | string | No | Comma-separated team/group IDs to attach |
| `environmentIds` | string | No | Comma-separated environment IDs to attach |
| `externalId` | string | No | External ID for the alert |
| `externalUrl` | string | No | External URL for the alert |
| `deduplicationKey` | string | No | Alerts sharing the same deduplication key are treated as a single alert |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `alert` | object | The created alert |
| ↳ `id` | string | Unique alert ID |
| ↳ `shortId` | string | Short alert ID |
| ↳ `summary` | string | Alert summary |
| ↳ `description` | string | Alert description |
| ↳ `source` | string | Alert source |
| ↳ `status` | string | Alert status |
| ↳ `externalId` | string | External ID |
| ↳ `externalUrl` | string | External URL |
| ↳ `deduplicationKey` | string | Deduplication key |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| ↳ `startedAt` | string | Start date |
| ↳ `endedAt` | string | End date |
### `rootly_list_alerts`
List alerts from Rootly with optional filtering by status, source, and services.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `status` | string | No | Filter by status \(open, triggered, acknowledged, resolved\) |
| `source` | string | No | Filter by source \(e.g., api, datadog, pagerduty\) |
| `services` | string | No | Filter by service slugs \(comma-separated\) |
| `environments` | string | No | Filter by environment slugs \(comma-separated\) |
| `groups` | string | No | Filter by team/group slugs \(comma-separated\) |
| `pageSize` | number | No | Number of items per page \(default: 20\) |
| `pageNumber` | number | No | Page number for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `alerts` | array | List of alerts |
| ↳ `id` | string | Unique alert ID |
| ↳ `shortId` | string | Short alert ID |
| ↳ `summary` | string | Alert summary |
| ↳ `description` | string | Alert description |
| ↳ `source` | string | Alert source |
| ↳ `status` | string | Alert status |
| ↳ `externalId` | string | External ID |
| ↳ `externalUrl` | string | External URL |
| ↳ `deduplicationKey` | string | Deduplication key |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| ↳ `startedAt` | string | Start date |
| ↳ `endedAt` | string | End date |
| `totalCount` | number | Total number of alerts returned |
### `rootly_add_incident_event`
Add a timeline event to an existing incident in Rootly.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `incidentId` | string | Yes | The ID of the incident to add the event to |
| `event` | string | Yes | The summary/description of the event |
| `visibility` | string | No | Event visibility \(internal or external\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `eventId` | string | The ID of the created event |
| `event` | string | The event summary |
| `visibility` | string | Event visibility \(internal or external\) |
| `occurredAt` | string | When the event occurred |
| `createdAt` | string | Creation date |
| `updatedAt` | string | Last update date |
### `rootly_list_services`
List services from Rootly with optional search filtering.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `search` | string | No | Search term to filter services |
| `pageSize` | number | No | Number of items per page \(default: 20\) |
| `pageNumber` | number | No | Page number for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `services` | array | List of services |
| ↳ `id` | string | Unique service ID |
| ↳ `name` | string | Service name |
| ↳ `slug` | string | Service slug |
| ↳ `description` | string | Service description |
| ↳ `color` | string | Service color |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| `totalCount` | number | Total number of services returned |
### `rootly_list_severities`
List severity levels configured in Rootly.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `search` | string | No | Search term to filter severities |
| `pageSize` | number | No | Number of items per page \(default: 20\) |
| `pageNumber` | number | No | Page number for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `severities` | array | List of severity levels |
| ↳ `id` | string | Unique severity ID |
| ↳ `name` | string | Severity name |
| ↳ `slug` | string | Severity slug |
| ↳ `description` | string | Severity description |
| ↳ `severity` | string | Severity level \(critical, high, medium, low\) |
| ↳ `color` | string | Severity color |
| ↳ `position` | number | Display position |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| `totalCount` | number | Total number of severities returned |
### `rootly_list_teams`
List teams (groups) configured in Rootly.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `search` | string | No | Search term to filter teams |
| `pageSize` | number | No | Number of items per page \(default: 20\) |
| `pageNumber` | number | No | Page number for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `teams` | array | List of teams |
| ↳ `id` | string | Unique team ID |
| ↳ `name` | string | Team name |
| ↳ `slug` | string | Team slug |
| ↳ `description` | string | Team description |
| ↳ `color` | string | Team color |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| `totalCount` | number | Total number of teams returned |
### `rootly_list_environments`
List environments configured in Rootly.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `search` | string | No | Search term to filter environments |
| `pageSize` | number | No | Number of items per page \(default: 20\) |
| `pageNumber` | number | No | Page number for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `environments` | array | List of environments |
| ↳ `id` | string | Unique environment ID |
| ↳ `name` | string | Environment name |
| ↳ `slug` | string | Environment slug |
| ↳ `description` | string | Environment description |
| ↳ `color` | string | Environment color |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| `totalCount` | number | Total number of environments returned |
### `rootly_list_incident_types`
List incident types configured in Rootly.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `search` | string | No | Filter incident types by name |
| `pageSize` | number | No | Number of items per page \(default: 20\) |
| `pageNumber` | number | No | Page number for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `incidentTypes` | array | List of incident types |
| ↳ `id` | string | Unique incident type ID |
| ↳ `name` | string | Incident type name |
| ↳ `slug` | string | Incident type slug |
| ↳ `description` | string | Incident type description |
| ↳ `color` | string | Incident type color |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| `totalCount` | number | Total number of incident types returned |
### `rootly_list_functionalities`
List functionalities configured in Rootly.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `search` | string | No | Search term to filter functionalities |
| `pageSize` | number | No | Number of items per page \(default: 20\) |
| `pageNumber` | number | No | Page number for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `functionalities` | array | List of functionalities |
| ↳ `id` | string | Unique functionality ID |
| ↳ `name` | string | Functionality name |
| ↳ `slug` | string | Functionality slug |
| ↳ `description` | string | Functionality description |
| ↳ `color` | string | Functionality color |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| `totalCount` | number | Total number of functionalities returned |
### `rootly_list_retrospectives`
List incident retrospectives (post-mortems) from Rootly.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `status` | string | No | Filter by status \(draft, published\) |
| `search` | string | No | Search term to filter retrospectives |
| `pageSize` | number | No | Number of items per page \(default: 20\) |
| `pageNumber` | number | No | Page number for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `retrospectives` | array | List of retrospectives |
| ↳ `id` | string | Unique retrospective ID |
| ↳ `title` | string | Retrospective title |
| ↳ `status` | string | Status \(draft or published\) |
| ↳ `url` | string | URL to the retrospective |
| ↳ `startedAt` | string | Incident start date |
| ↳ `mitigatedAt` | string | Mitigation date |
| ↳ `resolvedAt` | string | Resolution date |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| `totalCount` | number | Total number of retrospectives returned |
### `rootly_delete_incident`
Delete an incident by ID from Rootly.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `incidentId` | string | Yes | The ID of the incident to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the deletion succeeded |
| `message` | string | Result message |
### `rootly_get_alert`
Retrieve a single alert by ID from Rootly.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `alertId` | string | Yes | The ID of the alert to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `alert` | object | The alert details |
| ↳ `id` | string | Unique alert ID |
| ↳ `shortId` | string | Short alert ID |
| ↳ `summary` | string | Alert summary |
| ↳ `description` | string | Alert description |
| ↳ `source` | string | Alert source |
| ↳ `status` | string | Alert status |
| ↳ `externalId` | string | External ID |
| ↳ `externalUrl` | string | External URL |
| ↳ `deduplicationKey` | string | Deduplication key |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| ↳ `startedAt` | string | Start date |
| ↳ `endedAt` | string | End date |
### `rootly_update_alert`
Update an existing alert in Rootly.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `alertId` | string | Yes | The ID of the alert to update |
| `summary` | string | No | Updated alert summary |
| `description` | string | No | Updated alert description |
| `source` | string | No | Updated alert source |
| `serviceIds` | string | No | Comma-separated service IDs to attach |
| `groupIds` | string | No | Comma-separated team/group IDs to attach |
| `environmentIds` | string | No | Comma-separated environment IDs to attach |
| `externalId` | string | No | Updated external ID |
| `externalUrl` | string | No | Updated external URL |
| `deduplicationKey` | string | No | Updated deduplication key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `alert` | object | The updated alert |
| ↳ `id` | string | Unique alert ID |
| ↳ `shortId` | string | Short alert ID |
| ↳ `summary` | string | Alert summary |
| ↳ `description` | string | Alert description |
| ↳ `source` | string | Alert source |
| ↳ `status` | string | Alert status |
| ↳ `externalId` | string | External ID |
| ↳ `externalUrl` | string | External URL |
| ↳ `deduplicationKey` | string | Deduplication key |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| ↳ `startedAt` | string | Start date |
| ↳ `endedAt` | string | End date |
### `rootly_acknowledge_alert`
Acknowledge an alert in Rootly.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `alertId` | string | Yes | The ID of the alert to acknowledge |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `alert` | object | The acknowledged alert |
| ↳ `id` | string | Unique alert ID |
| ↳ `shortId` | string | Short alert ID |
| ↳ `summary` | string | Alert summary |
| ↳ `description` | string | Alert description |
| ↳ `source` | string | Alert source |
| ↳ `status` | string | Alert status |
| ↳ `externalId` | string | External ID |
| ↳ `externalUrl` | string | External URL |
| ↳ `deduplicationKey` | string | Deduplication key |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| ↳ `startedAt` | string | Start date |
| ↳ `endedAt` | string | End date |
### `rootly_resolve_alert`
Resolve an alert in Rootly.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `alertId` | string | Yes | The ID of the alert to resolve |
| `resolutionMessage` | string | No | Message describing how the alert was resolved |
| `resolveRelatedIncidents` | boolean | No | Whether to also resolve related incidents |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `alert` | object | The resolved alert |
| ↳ `id` | string | Unique alert ID |
| ↳ `shortId` | string | Short alert ID |
| ↳ `summary` | string | Alert summary |
| ↳ `description` | string | Alert description |
| ↳ `source` | string | Alert source |
| ↳ `status` | string | Alert status |
| ↳ `externalId` | string | External ID |
| ↳ `externalUrl` | string | External URL |
| ↳ `deduplicationKey` | string | Deduplication key |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| ↳ `startedAt` | string | Start date |
| ↳ `endedAt` | string | End date |
### `rootly_create_action_item`
Create a new action item for an incident in Rootly.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `incidentId` | string | Yes | The ID of the incident to add the action item to |
| `summary` | string | Yes | The title of the action item |
| `description` | string | No | A detailed description of the action item |
| `kind` | string | No | The kind of action item \(task, follow_up\) |
| `priority` | string | No | Priority level \(high, medium, low\) |
| `status` | string | No | Action item status \(open, in_progress, cancelled, done\) |
| `assignedToUserId` | string | No | The user ID to assign the action item to |
| `dueDate` | string | No | Due date for the action item |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `actionItem` | object | The created action item |
| ↳ `id` | string | Unique action item ID |
| ↳ `summary` | string | Action item title |
| ↳ `description` | string | Action item description |
| ↳ `kind` | string | Action item kind \(task, follow_up\) |
| ↳ `priority` | string | Priority level |
| ↳ `status` | string | Action item status |
| ↳ `dueDate` | string | Due date |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
### `rootly_list_action_items`
List action items for an incident in Rootly.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `incidentId` | string | Yes | The ID of the incident to list action items for |
| `pageSize` | number | No | Number of items per page \(default: 20\) |
| `pageNumber` | number | No | Page number for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `actionItems` | array | List of action items |
| ↳ `id` | string | Unique action item ID |
| ↳ `summary` | string | Action item title |
| ↳ `description` | string | Action item description |
| ↳ `kind` | string | Action item kind \(task, follow_up\) |
| ↳ `priority` | string | Priority level |
| ↳ `status` | string | Action item status |
| ↳ `dueDate` | string | Due date |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| `totalCount` | number | Total number of action items returned |
### `rootly_list_users`
List users from Rootly with optional search and email filtering.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `search` | string | No | Search term to filter users |
| `email` | string | No | Filter users by email address |
| `pageSize` | number | No | Number of items per page \(default: 20\) |
| `pageNumber` | number | No | Page number for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `users` | array | List of users |
| ↳ `id` | string | Unique user ID |
| ↳ `email` | string | User email address |
| ↳ `firstName` | string | User first name |
| ↳ `lastName` | string | User last name |
| ↳ `fullName` | string | User full name |
| ↳ `timeZone` | string | User time zone |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| `totalCount` | number | Total number of users returned |
### `rootly_list_on_calls`
List current on-call entries from Rootly with optional filtering.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `scheduleIds` | string | No | Comma-separated schedule IDs to filter by |
| `escalationPolicyIds` | string | No | Comma-separated escalation policy IDs to filter by |
| `userIds` | string | No | Comma-separated user IDs to filter by |
| `serviceIds` | string | No | Comma-separated service IDs to filter by |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `onCalls` | array | List of on-call entries |
| ↳ `id` | string | Unique on-call entry ID |
| ↳ `userId` | string | ID of the on-call user |
| ↳ `userName` | string | Name of the on-call user |
| ↳ `scheduleId` | string | ID of the associated schedule |
| ↳ `scheduleName` | string | Name of the associated schedule |
| ↳ `escalationPolicyId` | string | ID of the associated escalation policy |
| ↳ `startTime` | string | On-call start time |
| ↳ `endTime` | string | On-call end time |
| `totalCount` | number | Total number of on-call entries returned |
### `rootly_list_schedules`
List on-call schedules from Rootly with optional search filtering.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `search` | string | No | Search term to filter schedules |
| `pageSize` | number | No | Number of items per page \(default: 20\) |
| `pageNumber` | number | No | Page number for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `schedules` | array | List of schedules |
| ↳ `id` | string | Unique schedule ID |
| ↳ `name` | string | Schedule name |
| ↳ `description` | string | Schedule description |
| ↳ `allTimeCoverage` | boolean | Whether schedule provides 24/7 coverage |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| `totalCount` | number | Total number of schedules returned |
### `rootly_list_escalation_policies`
List escalation policies from Rootly with optional search filtering.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `search` | string | No | Search term to filter escalation policies |
| `pageSize` | number | No | Number of items per page \(default: 20\) |
| `pageNumber` | number | No | Page number for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `escalationPolicies` | array | List of escalation policies |
| ↳ `id` | string | Unique escalation policy ID |
| ↳ `name` | string | Escalation policy name |
| ↳ `description` | string | Escalation policy description |
| ↳ `repeatCount` | number | Number of times to repeat escalation |
| ↳ `groupIds` | array | Associated group IDs |
| ↳ `serviceIds` | array | Associated service IDs |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| `totalCount` | number | Total number of escalation policies returned |
### `rootly_list_causes`
List causes from Rootly with optional search filtering.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `search` | string | No | Search term to filter causes |
| `pageSize` | number | No | Number of items per page \(default: 20\) |
| `pageNumber` | number | No | Page number for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `causes` | array | List of causes |
| ↳ `id` | string | Unique cause ID |
| ↳ `name` | string | Cause name |
| ↳ `slug` | string | Cause slug |
| ↳ `description` | string | Cause description |
| ↳ `position` | number | Cause position |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| `totalCount` | number | Total number of causes returned |
### `rootly_list_playbooks`
List playbooks from Rootly with pagination support.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `pageSize` | number | No | Number of items per page \(default: 20\) |
| `pageNumber` | number | No | Page number for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `playbooks` | array | List of playbooks |
| ↳ `id` | string | Unique playbook ID |
| ↳ `title` | string | Playbook title |
| ↳ `summary` | string | Playbook summary |
| ↳ `externalUrl` | string | External URL |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| `totalCount` | number | Total number of playbooks returned |

View File

@@ -0,0 +1,157 @@
---
title: AWS Secrets Manager
description: Connect to AWS Secrets Manager
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="secrets_manager"
color="linear-gradient(45deg, #BD0816 0%, #FF5252 100%)"
/>
{/* MANUAL-CONTENT-START:intro */}
[AWS Secrets Manager](https://aws.amazon.com/secrets-manager/) is a secrets management service that helps you protect access to your applications, services, and IT resources. It enables you to rotate, manage, and retrieve database credentials, API keys, and other secrets throughout their lifecycle.
With AWS Secrets Manager, you can:
- **Securely store secrets**: Encrypt secrets at rest using AWS KMS encryption keys
- **Retrieve secrets programmatically**: Access secrets from your applications and workflows without hardcoding credentials
- **Rotate secrets automatically**: Configure automatic rotation for supported services like RDS, Redshift, and DocumentDB
- **Audit access**: Track secret access and changes through AWS CloudTrail integration
- **Control access with IAM**: Use fine-grained IAM policies to manage who can access which secrets
- **Replicate across regions**: Automatically replicate secrets to multiple AWS regions for disaster recovery
In Sim, the AWS Secrets Manager integration allows your workflows to securely retrieve credentials and configuration values at runtime, create and manage secrets as part of automation pipelines, and maintain a centralized secrets store that your agents can access. This is particularly useful for workflows that need to authenticate with external services, rotate credentials, or manage sensitive configuration across environments — all without exposing secrets in your workflow definitions.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate AWS Secrets Manager into the workflow. Can retrieve, create, update, list, and delete secrets.
## Tools
### `secrets_manager_get_secret`
Retrieve a secret value from AWS Secrets Manager
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
| `accessKeyId` | string | Yes | AWS access key ID |
| `secretAccessKey` | string | Yes | AWS secret access key |
| `secretId` | string | Yes | The name or ARN of the secret to retrieve |
| `versionId` | string | No | The unique identifier of the version to retrieve |
| `versionStage` | string | No | The staging label of the version to retrieve \(e.g., AWSCURRENT, AWSPREVIOUS\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `name` | string | Name of the secret |
| `secretValue` | string | The decrypted secret value |
| `arn` | string | ARN of the secret |
| `versionId` | string | Version ID of the secret |
| `versionStages` | array | Staging labels attached to this version |
| `createdDate` | string | Date the secret was created |
### `secrets_manager_list_secrets`
List secrets stored in AWS Secrets Manager
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
| `accessKeyId` | string | Yes | AWS access key ID |
| `secretAccessKey` | string | Yes | AWS secret access key |
| `maxResults` | number | No | Maximum number of secrets to return \(1-100, default 100\) |
| `nextToken` | string | No | Pagination token from a previous request |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `secrets` | json | List of secrets with name, ARN, description, and dates |
| `nextToken` | string | Pagination token for the next page of results |
| `count` | number | Number of secrets returned |
### `secrets_manager_create_secret`
Create a new secret in AWS Secrets Manager
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
| `accessKeyId` | string | Yes | AWS access key ID |
| `secretAccessKey` | string | Yes | AWS secret access key |
| `name` | string | Yes | Name of the secret to create |
| `secretValue` | string | Yes | The secret value \(plain text or JSON string\) |
| `description` | string | No | Description of the secret |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `name` | string | Name of the created secret |
| `arn` | string | ARN of the created secret |
| `versionId` | string | Version ID of the created secret |
### `secrets_manager_update_secret`
Update the value of an existing secret in AWS Secrets Manager
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
| `accessKeyId` | string | Yes | AWS access key ID |
| `secretAccessKey` | string | Yes | AWS secret access key |
| `secretId` | string | Yes | The name or ARN of the secret to update |
| `secretValue` | string | Yes | The new secret value \(plain text or JSON string\) |
| `description` | string | No | Updated description of the secret |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `name` | string | Name of the updated secret |
| `arn` | string | ARN of the updated secret |
| `versionId` | string | Version ID of the updated secret |
### `secrets_manager_delete_secret`
Delete a secret from AWS Secrets Manager
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
| `accessKeyId` | string | Yes | AWS access key ID |
| `secretAccessKey` | string | Yes | AWS secret access key |
| `secretId` | string | Yes | The name or ARN of the secret to delete |
| `recoveryWindowInDays` | number | No | Number of days before permanent deletion \(7-30, default 30\) |
| `forceDelete` | boolean | No | If true, immediately delete without recovery window |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `name` | string | Name of the deleted secret |
| `arn` | string | ARN of the deleted secret |
| `deletionDate` | string | Scheduled deletion date |

View File

@@ -0,0 +1,498 @@
---
title: Tailscale
description: Manage devices and network settings in your Tailscale tailnet
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="tailscale"
color="#2E2D2D"
/>
{/* MANUAL-CONTENT-START:intro */}
## Overview
[Tailscale](https://tailscale.com) is a zero-config mesh VPN built on WireGuard that makes it easy to connect devices, services, and users across any network. The Tailscale block lets you automate network management tasks like device provisioning, access control, route management, and DNS configuration directly from your Sim workflows.
## Authentication
The Tailscale block uses API key authentication. To get an API key:
1. Go to the [Tailscale admin console](https://login.tailscale.com/admin/settings/keys)
2. Navigate to **Settings > Keys**
3. Click **Generate API key**
4. Set an expiry (1-90 days) and copy the key (starts with `tskey-api-`)
You must have an **Owner**, **Admin**, **IT admin**, or **Network admin** role to generate API keys.
## Tailnet Identifier
Every operation requires a **tailnet** parameter. This is typically your organization's domain name (e.g., `example.com`). You can also use `"-"` to refer to your default tailnet.
## Common Use Cases
- **Device inventory**: List and monitor all devices connected to your network
- **Automated provisioning**: Create and manage auth keys to pre-authorize new devices
- **Access control**: Authorize or deauthorize devices, manage device tags for ACL policies
- **Route management**: View and enable subnet routes for devices acting as subnet routers
- **DNS management**: Configure nameservers, MagicDNS, and search paths
- **Key lifecycle**: Create, list, inspect, and revoke auth keys
- **User auditing**: List all users in the tailnet and their roles
- **Policy review**: Retrieve the current ACL policy for inspection or backup
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Interact with the Tailscale API to manage devices, DNS, ACLs, auth keys, users, and routes across your tailnet.
## Tools
### `tailscale_list_devices`
List all devices in the tailnet
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `devices` | array | List of devices in the tailnet |
| ↳ `id` | string | Device ID |
| ↳ `name` | string | Device name |
| ↳ `hostname` | string | Device hostname |
| ↳ `user` | string | Associated user |
| ↳ `os` | string | Operating system |
| ↳ `clientVersion` | string | Tailscale client version |
| ↳ `addresses` | array | Tailscale IP addresses |
| ↳ `tags` | array | Device tags |
| ↳ `authorized` | boolean | Whether the device is authorized |
| ↳ `blocksIncomingConnections` | boolean | Whether the device blocks incoming connections |
| ↳ `lastSeen` | string | Last seen timestamp |
| ↳ `created` | string | Creation timestamp |
| `count` | number | Total number of devices |
### `tailscale_get_device`
Get details of a specific device by ID
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
| `deviceId` | string | Yes | Device ID |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Device ID |
| `name` | string | Device name |
| `hostname` | string | Device hostname |
| `user` | string | Associated user |
| `os` | string | Operating system |
| `clientVersion` | string | Tailscale client version |
| `addresses` | array | Tailscale IP addresses |
| `tags` | array | Device tags |
| `authorized` | boolean | Whether the device is authorized |
| `blocksIncomingConnections` | boolean | Whether the device blocks incoming connections |
| `lastSeen` | string | Last seen timestamp |
| `created` | string | Creation timestamp |
| `isExternal` | boolean | Whether the device is external |
| `updateAvailable` | boolean | Whether an update is available |
| `machineKey` | string | Machine key |
| `nodeKey` | string | Node key |
### `tailscale_delete_device`
Remove a device from the tailnet
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
| `deviceId` | string | Yes | Device ID to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the device was successfully deleted |
| `deviceId` | string | ID of the deleted device |
### `tailscale_authorize_device`
Authorize or deauthorize a device on the tailnet
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
| `deviceId` | string | Yes | Device ID to authorize |
| `authorized` | boolean | Yes | Whether to authorize \(true\) or deauthorize \(false\) the device |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the operation succeeded |
| `deviceId` | string | Device ID |
| `authorized` | boolean | Authorization status after the operation |
### `tailscale_set_device_tags`
Set tags on a device in the tailnet
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
| `deviceId` | string | Yes | Device ID |
| `tags` | string | Yes | Comma-separated list of tags \(e.g., "tag:server,tag:production"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the tags were successfully set |
| `deviceId` | string | Device ID |
| `tags` | array | Tags set on the device |
### `tailscale_get_device_routes`
Get the subnet routes for a device
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
| `deviceId` | string | Yes | Device ID |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `advertisedRoutes` | array | Subnet routes the device is advertising |
| `enabledRoutes` | array | Subnet routes that are approved/enabled |
### `tailscale_set_device_routes`
Set the enabled subnet routes for a device
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
| `deviceId` | string | Yes | Device ID |
| `routes` | string | Yes | Comma-separated list of subnet routes to enable \(e.g., "10.0.0.0/24,192.168.1.0/24"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `advertisedRoutes` | array | Subnet routes the device is advertising |
| `enabledRoutes` | array | Subnet routes that are now enabled |
### `tailscale_update_device_key`
Enable or disable key expiry on a device
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
| `deviceId` | string | Yes | Device ID |
| `keyExpiryDisabled` | boolean | Yes | Whether to disable key expiry \(true\) or enable it \(false\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the operation succeeded |
| `deviceId` | string | Device ID |
| `keyExpiryDisabled` | boolean | Whether key expiry is now disabled |
### `tailscale_list_dns_nameservers`
Get the DNS nameservers configured for the tailnet
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `dns` | array | List of DNS nameserver addresses |
| `magicDNS` | boolean | Whether MagicDNS is enabled |
### `tailscale_set_dns_nameservers`
Set the DNS nameservers for the tailnet
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
| `dns` | string | Yes | Comma-separated list of DNS nameserver IP addresses \(e.g., "8.8.8.8,8.8.4.4"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `dns` | array | Updated list of DNS nameserver addresses |
| `magicDNS` | boolean | Whether MagicDNS is enabled |
### `tailscale_get_dns_preferences`
Get the DNS preferences for the tailnet including MagicDNS status
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `magicDNS` | boolean | Whether MagicDNS is enabled |
### `tailscale_set_dns_preferences`
Set DNS preferences for the tailnet (enable/disable MagicDNS)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
| `magicDNS` | boolean | Yes | Whether to enable \(true\) or disable \(false\) MagicDNS |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `magicDNS` | boolean | Updated MagicDNS status |
### `tailscale_get_dns_searchpaths`
Get the DNS search paths configured for the tailnet
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `searchPaths` | array | List of DNS search path domains |
### `tailscale_set_dns_searchpaths`
Set the DNS search paths for the tailnet
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
| `searchPaths` | string | Yes | Comma-separated list of DNS search path domains \(e.g., "corp.example.com,internal.example.com"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `searchPaths` | array | Updated list of DNS search path domains |
### `tailscale_list_users`
List all users in the tailnet
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `users` | array | List of users in the tailnet |
| ↳ `id` | string | User ID |
| ↳ `displayName` | string | Display name |
| ↳ `loginName` | string | Login name / email |
| ↳ `profilePicURL` | string | Profile picture URL |
| ↳ `role` | string | User role \(owner, admin, member, etc.\) |
| ↳ `status` | string | User status \(active, suspended, etc.\) |
| ↳ `type` | string | User type \(member, shared, tagged\) |
| ↳ `created` | string | Creation timestamp |
| ↳ `lastSeen` | string | Last seen timestamp |
| ↳ `deviceCount` | number | Number of devices owned by user |
| `count` | number | Total number of users |
### `tailscale_create_auth_key`
Create a new auth key for the tailnet to pre-authorize devices
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
| `reusable` | boolean | No | Whether the key can be used more than once |
| `ephemeral` | boolean | No | Whether devices authenticated with this key are ephemeral |
| `preauthorized` | boolean | No | Whether devices are pre-authorized \(skip manual approval\) |
| `tags` | string | No | Comma-separated list of tags for devices using this key \(e.g., "tag:server,tag:prod"\) |
| `description` | string | No | Description for the auth key |
| `expirySeconds` | number | No | Key expiry time in seconds \(default: 90 days\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Auth key ID |
| `key` | string | The auth key value \(only shown once at creation\) |
| `description` | string | Key description |
| `created` | string | Creation timestamp |
| `expires` | string | Expiration timestamp |
| `revoked` | string | Revocation timestamp \(empty if not revoked\) |
| `capabilities` | object | Key capabilities |
| ↳ `reusable` | boolean | Whether the key is reusable |
| ↳ `ephemeral` | boolean | Whether devices are ephemeral |
| ↳ `preauthorized` | boolean | Whether devices are pre-authorized |
| ↳ `tags` | array | Tags applied to devices using this key |
### `tailscale_list_auth_keys`
List all auth keys in the tailnet
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `keys` | array | List of auth keys |
| ↳ `id` | string | Auth key ID |
| ↳ `description` | string | Key description |
| ↳ `created` | string | Creation timestamp |
| ↳ `expires` | string | Expiration timestamp |
| ↳ `revoked` | string | Revocation timestamp |
| ↳ `capabilities` | object | Key capabilities |
| ↳ `reusable` | boolean | Whether the key is reusable |
| ↳ `ephemeral` | boolean | Whether devices are ephemeral |
| ↳ `preauthorized` | boolean | Whether devices are pre-authorized |
| ↳ `tags` | array | Tags applied to devices |
| `count` | number | Total number of auth keys |
### `tailscale_get_auth_key`
Get details of a specific auth key
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
| `keyId` | string | Yes | Auth key ID |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Auth key ID |
| `description` | string | Key description |
| `created` | string | Creation timestamp |
| `expires` | string | Expiration timestamp |
| `revoked` | string | Revocation timestamp |
| `capabilities` | object | Key capabilities |
| ↳ `reusable` | boolean | Whether the key is reusable |
| ↳ `ephemeral` | boolean | Whether devices are ephemeral |
| ↳ `preauthorized` | boolean | Whether devices are pre-authorized |
| ↳ `tags` | array | Tags applied to devices using this key |
### `tailscale_delete_auth_key`
Revoke and delete an auth key
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
| `keyId` | string | Yes | Auth key ID to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the auth key was successfully deleted |
| `keyId` | string | ID of the deleted auth key |
### `tailscale_get_acl`
Get the current ACL policy for the tailnet
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Tailscale API key |
| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `acl` | string | ACL policy as JSON string |
| `etag` | string | ETag for the current ACL version \(use with If-Match header for updates\) |

View File

@@ -131,7 +131,7 @@ Detecta información de identificación personal utilizando Microsoft Presidio.
**Casos de uso:**
- Bloquear contenido que contiene información personal sensible
- Enmascarar PII antes de registrar o almacenar datos
- Cumplimiento de GDPR, HIPAA y otras regulaciones de privacidad
- Cumplimiento de GDPR y otras regulaciones de privacidad
- Sanear entradas de usuario antes del procesamiento
## Configuración

View File

@@ -131,7 +131,7 @@ Détecte les informations personnelles identifiables à l'aide de Microsoft Pres
**Cas d'utilisation :**
- Bloquer le contenu contenant des informations personnelles sensibles
- Masquer les PII avant de journaliser ou stocker des données
- Conformité avec le RGPD, HIPAA et autres réglementations sur la confidentialité
- Conformité avec le RGPD et autres réglementations sur la confidentialité
- Assainir les entrées utilisateur avant traitement
## Configuration

View File

@@ -131,7 +131,7 @@ Microsoft Presidioを使用して個人を特定できる情報を検出しま
**ユースケース:**
- 機密性の高い個人情報を含むコンテンツをブロック
- データのログ記録や保存前にPIIをマスク
- GDPR、HIPAA、その他のプライバシー規制への準拠
- GDPR、その他のプライバシー規制への準拠
- 処理前のユーザー入力のサニタイズ
## 設定

View File

@@ -131,7 +131,7 @@ Guardrails 模块通过针对多种验证类型检查内容,验证并保护您
**使用场景:**
- 阻止包含敏感个人信息的内容
- 在记录或存储数据之前屏蔽 PII
- 符合 GDPR、HIPAA 和其他隐私法规
- 符合 GDPR 和其他隐私法规
- 在处理之前清理用户输入
## 配置

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@@ -28,6 +28,15 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener
# OLLAMA_URL=http://localhost:11434 # URL for local Ollama server - uncomment if using local models
# VLLM_BASE_URL=http://localhost:8000 # Base URL for your self-hosted vLLM (OpenAI-compatible)
# VLLM_API_KEY= # Optional bearer token if your vLLM instance requires auth
# FIREWORKS_API_KEY= # Optional Fireworks AI API key for model listing
# NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS=true # Set when using AWS default credential chain (IAM roles, ECS task roles, IRSA). Hides credential fields in Agent block UI.
# AZURE_OPENAI_ENDPOINT= # Azure OpenAI endpoint (hides field in UI when set alongside NEXT_PUBLIC_AZURE_CONFIGURED)
# AZURE_OPENAI_API_KEY= # Azure OpenAI API key
# AZURE_OPENAI_API_VERSION= # Azure OpenAI API version
# AZURE_ANTHROPIC_ENDPOINT= # Azure Anthropic endpoint (AI Foundry)
# AZURE_ANTHROPIC_API_KEY= # Azure Anthropic API key
# AZURE_ANTHROPIC_API_VERSION= # Azure Anthropic API version (e.g., 2023-06-01)
# NEXT_PUBLIC_AZURE_CONFIGURED=true # Set when Azure credentials are pre-configured above. Hides endpoint/key/version fields in Agent block UI.
# Admin API (Optional - for self-hosted GitOps)
# ADMIN_API_KEY= # Use `openssl rand -hex 32` to generate. Enables admin API for workflow export/import.

View File

@@ -1,3 +1,6 @@
/** Shared className for primary auth form submit buttons across all auth pages. */
export const AUTH_SUBMIT_BTN =
'inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50' as const
/** Shared className for primary auth/status CTA buttons on dark auth surfaces. */
export const AUTH_PRIMARY_CTA_BASE =
'inline-flex h-[32px] items-center justify-center gap-2 rounded-[5px] border border-[var(--auth-primary-btn-border)] bg-[var(--auth-primary-btn-bg)] px-2.5 font-[430] font-season text-[var(--auth-primary-btn-text)] text-sm transition-colors hover:border-[var(--auth-primary-btn-hover-border)] hover:bg-[var(--auth-primary-btn-hover-bg)] hover:text-[var(--auth-primary-btn-hover-text)] disabled:cursor-not-allowed disabled:opacity-50' as const
/** Full-width variant used for primary auth form submit buttons. */
export const AUTH_SUBMIT_BTN = `${AUTH_PRIMARY_CTA_BASE} w-full` as const

View File

@@ -288,7 +288,6 @@ export default function Collaboration() {
width={876}
height={480}
className='h-full w-auto object-left md:min-w-[100vw]'
priority
/>
</div>
<div className='hidden lg:block'>

View File

@@ -81,6 +81,56 @@ function ProviderPreviewIcon({ providerId }: { providerId?: string }) {
)
}
interface FeatureToggleItemProps {
feature: PermissionFeature
enabled: boolean
color: string
isInView: boolean
delay: number
textClassName: string
transition: Record<string, unknown>
onToggle: () => void
}
function FeatureToggleItem({
feature,
enabled,
color,
isInView,
delay,
textClassName,
transition,
onToggle,
}: FeatureToggleItemProps) {
return (
<motion.div
key={feature.key}
role='button'
tabIndex={0}
aria-label={`Toggle ${feature.name}`}
aria-pressed={enabled}
className='flex cursor-pointer items-center gap-2 rounded-[4px] py-0.5'
initial={{ opacity: 0, x: -6 }}
animate={isInView ? { opacity: 1, x: 0 } : {}}
transition={{ ...transition, delay }}
onClick={onToggle}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
}
}}
whileTap={{ scale: 0.98 }}
>
<CheckboxIcon checked={enabled} color={color} />
<ProviderPreviewIcon providerId={feature.providerId} />
<span className={textClassName} style={{ color: enabled ? '#F6F6F6AA' : '#F6F6F640' }}>
{feature.name}
</span>
</motion.div>
)
}
export function AccessControlPanel() {
const ref = useRef(null)
const isInView = useInView(ref, { once: true, margin: '-40px' })
@@ -97,39 +147,25 @@ export function AccessControlPanel() {
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]'>
<span className='font-[430] font-season text-[#F6F6F6]/55 text-[10px] uppercase leading-none tracking-[0.08em]'>
{category.label}
</span>
<div className='mt-2 grid grid-cols-2 gap-x-4 gap-y-2'>
{category.features.map((feature, featIdx) => {
const enabled = accessState[feature.key]
return (
<motion.div
key={feature.key}
className='flex cursor-pointer items-center gap-2 rounded-[4px] py-0.5'
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>
)
})}
{category.features.map((feature, featIdx) => (
<FeatureToggleItem
key={feature.key}
feature={feature}
enabled={accessState[feature.key]}
color={category.color}
isInView={isInView}
delay={0.05 + (offsetBefore + featIdx) * 0.04}
textClassName='truncate font-[430] font-season text-[13px] leading-none tracking-[0.02em]'
transition={{ duration: 0.3 }}
onToggle={() =>
setAccessState((prev) => ({ ...prev, [feature.key]: !prev[feature.key] }))
}
/>
))}
</div>
</div>
)
@@ -140,12 +176,11 @@ export function AccessControlPanel() {
<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]'>
<span className='font-[430] font-season text-[#F6F6F6]/55 text-[10px] uppercase leading-none tracking-[0.08em]'>
{category.label}
</span>
<div className='mt-2 grid grid-cols-2 gap-x-4 gap-y-2'>
{category.features.map((feature, featIdx) => {
const enabled = accessState[feature.key]
const currentIndex =
PERMISSION_CATEGORIES.slice(0, catIdx).reduce(
(sum, c) => sum + c.features.length,
@@ -153,30 +188,19 @@ export function AccessControlPanel() {
) + featIdx
return (
<motion.div
<FeatureToggleItem
key={feature.key}
className='flex cursor-pointer items-center gap-2 rounded-[4px] py-0.5'
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={() =>
feature={feature}
enabled={accessState[feature.key]}
color={category.color}
isInView={isInView}
delay={0.1 + currentIndex * 0.04}
textClassName='truncate font-[430] font-season text-[11px] leading-none tracking-[0.02em] transition-opacity duration-200'
transition={{ duration: 0.3, ease: [0.25, 0.46, 0.45, 0.94] }}
onToggle={() =>
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>

View File

@@ -146,14 +146,14 @@ function AuditRow({ entry, index }: AuditRowProps) {
</div>
{/* Time */}
<span className='w-[56px] shrink-0 font-[430] font-season text-[#F6F6F6]/30 text-[11px] leading-none tracking-[0.02em]'>
<span className='w-[56px] shrink-0 font-[430] font-season text-[#F6F6F6]/55 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]/60'> · </span>
<span className='text-[#F6F6F6]/55'>{entry.description}</span>
</span>
</span>
@@ -166,14 +166,14 @@ export 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) => ({
const [entries, setEntries] = useState<LogEntry[]>(() => {
const now = Date.now()
return ENTRY_TEMPLATES.slice(0, 6).map((t, i) => ({
...t,
id: i,
insertedAt: now - INITIAL_OFFSETS_MS[i],
}))
)
})
const [, tick] = useState(0)
useEffect(() => {
@@ -208,10 +208,9 @@ export function AuditLogPreview() {
exit={{ opacity: 0 }}
transition={{
layout: {
type: 'spring',
stiffness: 350,
damping: 50,
mass: 0.8,
type: 'tween',
duration: 0.32,
ease: [0.25, 0.46, 0.45, 0.94],
},
y: { duration: 0.32, ease: [0.25, 0.46, 0.45, 0.94] },
opacity: { duration: 0.25 },

View File

@@ -4,11 +4,11 @@
* SEO:
* - `<section id="enterprise" aria-labelledby="enterprise-heading">`.
* - `<h2 id="enterprise-heading">` for the section title.
* - Compliance certs (SOC 2, HIPAA) as visible `<strong>` text.
* - Compliance cert (SOC 2) as visible `<strong>` text.
* - Enterprise CTA links to contact form via `<a>` with `rel="noopener noreferrer"`.
*
* GEO:
* - Entity-rich: "Sim is SOC 2 and HIPAA compliant" — not "We are compliant."
* - Entity-rich: "Sim is SOC 2 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?".
*/
@@ -66,7 +66,7 @@ const FEATURE_TAGS = [
function TrustStrip() {
return (
<div className='mx-6 mt-4 grid grid-cols-1 overflow-hidden rounded-lg border border-[var(--landing-bg-elevated)] sm:grid-cols-3 md:mx-8'>
{/* SOC 2 + HIPAA combined */}
{/* SOC 2 */}
<Link
href='https://app.vanta.com/sim.ai/trust/v35ia0jil4l7dteqjgaktn'
target='_blank'
@@ -83,10 +83,10 @@ function TrustStrip() {
/>
<div className='flex flex-col gap-[3px]'>
<strong className='font-[430] font-season text-small text-white leading-none'>
SOC 2 & HIPAA
SOC 2
</strong>
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_30%,transparent)] text-xs leading-none tracking-[0.02em] transition-colors group-hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)]'>
Type II · PHI protected
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)] text-xs leading-none tracking-[0.02em] transition-colors group-hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_75%,transparent)]'>
Type II
</span>
</div>
</Link>
@@ -105,7 +105,7 @@ function TrustStrip() {
<strong className='font-[430] font-season text-small text-white leading-none'>
Open Source
</strong>
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_30%,transparent)] text-xs leading-none tracking-[0.02em] transition-colors group-hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)]'>
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)] text-xs leading-none tracking-[0.02em] transition-colors group-hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_75%,transparent)]'>
View on GitHub
</span>
</div>
@@ -120,7 +120,7 @@ function TrustStrip() {
<strong className='font-[430] font-season text-small text-white leading-none'>
SSO & SCIM
</strong>
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_30%,transparent)] text-xs leading-none tracking-[0.02em]'>
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)] text-xs leading-none tracking-[0.02em]'>
Okta, Azure AD, Google
</span>
</div>
@@ -165,7 +165,7 @@ export default function Enterprise() {
<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]'>
<p className='mt-2 max-w-[480px] font-[430] font-season text-[#F6F6F6]/70 text-[14px] leading-[150%] tracking-[0.02em]'>
Every action is captured with full actor attribution.
</p>
</div>
@@ -179,7 +179,7 @@ export default function Enterprise() {
<h3 className='font-[430] font-season text-[16px] text-white leading-[120%] tracking-[-0.01em]'>
Access Control
</h3>
<p className='mt-1.5 font-[430] font-season text-[#F6F6F6]/50 text-[14px] leading-[150%] tracking-[0.02em]'>
<p className='mt-1.5 font-[430] font-season text-[#F6F6F6]/70 text-[14px] leading-[150%] tracking-[0.02em]'>
Restrict providers, surfaces, and tools per group.
</p>
</div>
@@ -211,7 +211,7 @@ export default function Enterprise() {
(tag, i) => (
<span
key={i}
className='enterprise-feature-marquee-tag whitespace-nowrap border-[var(--landing-bg-elevated)] border-r px-5 py-4 font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_40%,transparent)] text-small leading-none tracking-[0.02em] hover:bg-white/[0.04] hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)]'
className='enterprise-feature-marquee-tag whitespace-nowrap border-[var(--landing-bg-elevated)] border-r px-5 py-4 font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-small leading-none tracking-[0.02em] hover:bg-white/[0.04] hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_80%,transparent)]'
>
{tag}
</span>
@@ -221,7 +221,7 @@ export default function Enterprise() {
</div>
<div className='flex items-center justify-between border-[var(--landing-bg-elevated)] border-t px-6 py-5 md:px-8 md:py-6'>
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_40%,transparent)] text-base leading-[150%] tracking-[0.02em]'>
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-base leading-[150%] tracking-[0.02em]'>
Ready for growth?
</p>
<DemoRequestModal>

View File

@@ -190,7 +190,6 @@ export default function Features() {
width={1440}
height={366}
className='h-auto w-full'
priority
/>
</div>

View File

@@ -67,6 +67,7 @@ export function FooterCTA() {
type='button'
onClick={handleSubmit}
disabled={isEmpty}
aria-label='Submit message'
className='flex h-[28px] w-[28px] items-center justify-center rounded-full border-0 p-0 transition-colors'
style={{
background: isEmpty ? '#C0C0C0' : '#1C1C1C',

View File

@@ -26,6 +26,9 @@ const RESOURCES_LINKS: FooterItem[] = [
{ label: 'Blog', href: '/blog' },
// { label: 'Templates', href: '/templates' },
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
{ label: 'Models', href: '/models' },
// { label: 'Academy', href: '/academy' },
{ label: 'Partners', href: '/partners' },
{ label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true },
{ label: 'Changelog', href: '/changelog' },
]

View File

@@ -42,7 +42,7 @@ export default function Hero() {
1,000+ integrations and LLMs including OpenAI, Claude, Gemini, Mistral, and xAI to
deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables,
and docs. Trusted by over 100,000 builders at startups and Fortune 500 companies. SOC2 and
HIPAA compliant.
SOC2 compliant.
</p>
<div

View File

@@ -0,0 +1,157 @@
import { File } from '@/components/emcn/icons'
import { DocxIcon, PdfIcon } from '@/components/icons/document-icons'
import type {
PreviewColumn,
PreviewRow,
} from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
import {
LandingPreviewResource,
ownerCell,
} from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
/** Generic audio/zip icon using basic SVG since no dedicated component exists */
function AudioIcon({ className }: { className?: string }) {
return (
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
className={className}
>
<path d='M9 18V5l12-2v13' />
<circle cx='6' cy='18' r='3' />
<circle cx='18' cy='16' r='3' />
</svg>
)
}
function JsonlIcon({ className }: { className?: string }) {
return (
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
className={className}
>
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' />
<path d='M14 2v4a2 2 0 0 0 2 2h4' />
<path d='M10 9H8' />
<path d='M16 13H8' />
<path d='M16 17H8' />
</svg>
)
}
function ZipIcon({ className }: { className?: string }) {
return (
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
className={className}
>
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' />
<path d='M14 2v4a2 2 0 0 0 2 2h4' />
<path d='M10 6h1' />
<path d='M10 10h1' />
<path d='M10 14h1' />
<path d='M9 18h2v2h-2z' />
</svg>
)
}
const COLUMNS: PreviewColumn[] = [
{ id: 'name', header: 'Name' },
{ id: 'size', header: 'Size' },
{ id: 'type', header: 'Type' },
{ id: 'created', header: 'Created' },
{ id: 'owner', header: 'Owner' },
]
const ROWS: PreviewRow[] = [
{
id: '1',
cells: {
name: { icon: <PdfIcon className='h-[14px] w-[14px]' />, label: 'Q1 Performance Report.pdf' },
size: { label: '2.4 MB' },
type: { icon: <PdfIcon className='h-[14px] w-[14px]' />, label: 'PDF' },
created: { label: '3 hours ago' },
owner: ownerCell('T', 'Theo L.'),
},
},
{
id: '2',
cells: {
name: { icon: <ZipIcon className='h-[14px] w-[14px]' />, label: 'product-screenshots.zip' },
size: { label: '18.7 MB' },
type: { icon: <ZipIcon className='h-[14px] w-[14px]' />, label: 'ZIP' },
created: { label: '1 day ago' },
owner: ownerCell('A', 'Alex M.'),
},
},
{
id: '3',
cells: {
name: { icon: <JsonlIcon className='h-[14px] w-[14px]' />, label: 'training-dataset.jsonl' },
size: { label: '892 KB' },
type: { icon: <JsonlIcon className='h-[14px] w-[14px]' />, label: 'JSONL' },
created: { label: '3 days ago' },
owner: ownerCell('J', 'Jordan P.'),
},
},
{
id: '4',
cells: {
name: { icon: <PdfIcon className='h-[14px] w-[14px]' />, label: 'brand-guidelines.pdf' },
size: { label: '5.1 MB' },
type: { icon: <PdfIcon className='h-[14px] w-[14px]' />, label: 'PDF' },
created: { label: '1 week ago' },
owner: ownerCell('S', 'Sarah K.'),
},
},
{
id: '5',
cells: {
name: { icon: <AudioIcon className='h-[14px] w-[14px]' />, label: 'customer-interviews.mp3' },
size: { label: '45.2 MB' },
type: { icon: <AudioIcon className='h-[14px] w-[14px]' />, label: 'Audio' },
created: { label: 'March 20th, 2026' },
owner: ownerCell('V', 'Vik M.'),
},
},
{
id: '6',
cells: {
name: { icon: <DocxIcon className='h-[14px] w-[14px]' />, label: 'onboarding-playbook.docx' },
size: { label: '1.1 MB' },
type: { icon: <DocxIcon className='h-[14px] w-[14px]' />, label: 'DOCX' },
created: { label: 'March 14th, 2026' },
owner: ownerCell('S', 'Sarah K.'),
},
},
]
/**
* Static landing preview of the Files workspace page.
*/
export function LandingPreviewFiles() {
return (
<LandingPreviewResource
icon={File}
title='Files'
createLabel='Upload file'
searchPlaceholder='Search files...'
columns={COLUMNS}
rows={ROWS}
/>
)
}

View File

@@ -0,0 +1,105 @@
import { Database } from '@/components/emcn/icons'
import {
AirtableIcon,
AsanaIcon,
ConfluenceIcon,
GoogleDocsIcon,
GoogleDriveIcon,
JiraIcon,
SalesforceIcon,
SlackIcon,
ZendeskIcon,
} from '@/components/icons'
import type {
PreviewColumn,
PreviewRow,
} from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
import { LandingPreviewResource } from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
const DB_ICON = <Database className='h-[14px] w-[14px]' />
function connectorIcons(icons: React.ComponentType<{ className?: string }>[]) {
return {
content: (
<div className='flex items-center gap-1'>
{icons.map((Icon, i) => (
<Icon key={i} className='h-3.5 w-3.5 flex-shrink-0' />
))}
</div>
),
}
}
const COLUMNS: PreviewColumn[] = [
{ id: 'name', header: 'Name' },
{ id: 'documents', header: 'Documents' },
{ id: 'tokens', header: 'Tokens' },
{ id: 'connectors', header: 'Connectors' },
{ id: 'created', header: 'Created' },
]
const ROWS: PreviewRow[] = [
{
id: '1',
cells: {
name: { icon: DB_ICON, label: 'Product Documentation' },
documents: { label: '847' },
tokens: { label: '1,284,392' },
connectors: connectorIcons([AsanaIcon, GoogleDocsIcon]),
created: { label: '2 days ago' },
},
},
{
id: '2',
cells: {
name: { icon: DB_ICON, label: 'Customer Support KB' },
documents: { label: '234' },
tokens: { label: '892,104' },
connectors: connectorIcons([ZendeskIcon, SlackIcon]),
created: { label: '1 week ago' },
},
},
{
id: '3',
cells: {
name: { icon: DB_ICON, label: 'Engineering Wiki' },
documents: { label: '1,203' },
tokens: { label: '2,847,293' },
connectors: connectorIcons([ConfluenceIcon, JiraIcon]),
created: { label: 'March 12th, 2026' },
},
},
{
id: '4',
cells: {
name: { icon: DB_ICON, label: 'Marketing Assets' },
documents: { label: '189' },
tokens: { label: '634,821' },
connectors: connectorIcons([GoogleDriveIcon, AirtableIcon]),
created: { label: 'March 5th, 2026' },
},
},
{
id: '5',
cells: {
name: { icon: DB_ICON, label: 'Sales Playbook' },
documents: { label: '92' },
tokens: { label: '418,570' },
connectors: connectorIcons([SalesforceIcon]),
created: { label: 'February 28th, 2026' },
},
},
]
export function LandingPreviewKnowledge() {
return (
<LandingPreviewResource
icon={Database}
title='Knowledge Base'
createLabel='New base'
searchPlaceholder='Search knowledge bases...'
columns={COLUMNS}
rows={ROWS}
/>
)
}

View File

@@ -0,0 +1,321 @@
'use client'
import { useMemo, useState } from 'react'
import { Download } from 'lucide-react'
import { ArrowUpDown, Badge, Library, ListFilter, Search } from '@/components/emcn'
import type { BadgeProps } from '@/components/emcn/components/badge/badge'
import { cn } from '@/lib/core/utils/cn'
interface LogRow {
id: string
workflowName: string
workflowColor: string
date: string
status: 'completed' | 'error' | 'running'
cost: string
trigger: 'webhook' | 'api' | 'schedule' | 'manual' | 'mcp' | 'chat'
triggerLabel: string
duration: string
}
type BadgeVariant = BadgeProps['variant']
const STATUS_VARIANT: Record<LogRow['status'], BadgeVariant> = {
completed: 'gray',
error: 'red',
running: 'amber',
}
const STATUS_LABELS: Record<LogRow['status'], string> = {
completed: 'Completed',
error: 'Error',
running: 'Running',
}
const TRIGGER_VARIANT: Record<LogRow['trigger'], BadgeVariant> = {
webhook: 'orange',
api: 'blue',
schedule: 'green',
manual: 'gray-secondary',
mcp: 'cyan',
chat: 'purple',
}
const MOCK_LOGS: LogRow[] = [
{
id: '1',
workflowName: 'Customer Onboarding',
workflowColor: '#4f8ef7',
date: 'Apr 1 10:42 AM',
status: 'running',
cost: '-',
trigger: 'webhook',
triggerLabel: 'Webhook',
duration: '-',
},
{
id: '2',
workflowName: 'Lead Enrichment',
workflowColor: '#33C482',
date: 'Apr 1 09:15 AM',
status: 'error',
cost: '318 credits',
trigger: 'api',
triggerLabel: 'API',
duration: '2.7s',
},
{
id: '3',
workflowName: 'Email Campaign',
workflowColor: '#a855f7',
date: 'Apr 1 08:30 AM',
status: 'completed',
cost: '89 credits',
trigger: 'schedule',
triggerLabel: 'Schedule',
duration: '0.8s',
},
{
id: '4',
workflowName: 'Data Pipeline',
workflowColor: '#f97316',
date: 'Mar 31 10:14 PM',
status: 'completed',
cost: '241 credits',
trigger: 'webhook',
triggerLabel: 'Webhook',
duration: '4.1s',
},
{
id: '5',
workflowName: 'Invoice Processing',
workflowColor: '#ec4899',
date: 'Mar 31 08:45 PM',
status: 'completed',
cost: '112 credits',
trigger: 'manual',
triggerLabel: 'Manual',
duration: '0.9s',
},
{
id: '6',
workflowName: 'Support Triage',
workflowColor: '#0ea5e9',
date: 'Mar 31 07:22 PM',
status: 'completed',
cost: '197 credits',
trigger: 'api',
triggerLabel: 'API',
duration: '1.6s',
},
{
id: '7',
workflowName: 'Content Moderator',
workflowColor: '#f59e0b',
date: 'Mar 31 06:11 PM',
status: 'error',
cost: '284 credits',
trigger: 'schedule',
triggerLabel: 'Schedule',
duration: '3.2s',
},
]
type SortKey = 'workflowName' | 'date' | 'status' | 'cost' | 'trigger' | 'duration'
const COL_HEADERS: { key: SortKey; label: string }[] = [
{ key: 'workflowName', label: 'Workflow' },
{ key: 'date', label: 'Date' },
{ key: 'status', label: 'Status' },
{ key: 'cost', label: 'Cost' },
{ key: 'trigger', label: 'Trigger' },
{ key: 'duration', label: 'Duration' },
]
export function LandingPreviewLogs() {
const [search, setSearch] = useState('')
const [sortKey, setSortKey] = useState<SortKey | null>(null)
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
const [activeTab, setActiveTab] = useState<'logs' | 'dashboard'>('logs')
function handleSort(key: SortKey) {
if (sortKey === key) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
} else {
setSortKey(key)
setSortDir('asc')
}
}
const sorted = useMemo(() => {
const q = search.toLowerCase()
const filtered = q
? MOCK_LOGS.filter(
(log) =>
log.workflowName.toLowerCase().includes(q) ||
log.triggerLabel.toLowerCase().includes(q) ||
STATUS_LABELS[log.status].toLowerCase().includes(q)
)
: MOCK_LOGS
if (!sortKey) return filtered
return [...filtered].sort((a, b) => {
const av = sortKey === 'cost' ? a.cost.replace(/\D/g, '') : a[sortKey]
const bv = sortKey === 'cost' ? b.cost.replace(/\D/g, '') : b[sortKey]
const cmp = av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' })
return sortDir === 'asc' ? cmp : -cmp
})
}, [search, sortKey, sortDir])
return (
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
{/* Header */}
<div className='border-[var(--border)] border-b px-6 py-2.5'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-3'>
<Library className='h-[14px] w-[14px] text-[var(--text-icon)]' />
<h1 className='font-medium text-[var(--text-body)] text-sm'>Logs</h1>
</div>
<div className='flex items-center gap-1'>
<div className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption'>
<Download className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
Export
</div>
<button
type='button'
onClick={() => setActiveTab('logs')}
className='rounded-md px-2 py-1 text-caption transition-colors'
style={{
backgroundColor: activeTab === 'logs' ? 'var(--surface-active)' : 'transparent',
color: activeTab === 'logs' ? 'var(--text-body)' : 'var(--text-secondary)',
}}
>
Logs
</button>
<button
type='button'
onClick={() => setActiveTab('dashboard')}
className='rounded-md px-2 py-1 text-caption transition-colors'
style={{
backgroundColor:
activeTab === 'dashboard' ? 'var(--surface-active)' : 'transparent',
color: activeTab === 'dashboard' ? 'var(--text-body)' : 'var(--text-secondary)',
}}
>
Dashboard
</button>
</div>
</div>
</div>
{/* Options bar */}
<div className='border-[var(--border)] border-b px-6 py-2.5'>
<div className='flex items-center justify-between'>
<div className='flex flex-1 items-center gap-2.5'>
<Search className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<input
type='text'
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder='Search logs...'
className='flex-1 bg-transparent text-[var(--text-body)] text-caption outline-none placeholder:text-[var(--text-subtle)]'
/>
</div>
<div className='flex items-center gap-1.5'>
<div className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption'>
<ListFilter className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
Filter
</div>
<button
type='button'
onClick={() => handleSort(sortKey ?? 'workflowName')}
className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-3)]'
>
<ArrowUpDown className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
Sort
</button>
</div>
</div>
</div>
{/* Table — uses <table> for pixel-perfect column alignment with headers */}
<div className='min-h-0 flex-1 overflow-hidden'>
<table className='w-full table-fixed text-sm'>
<colgroup>
<col style={{ width: '22%' }} />
<col style={{ width: '18%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '14%' }} />
<col style={{ width: '18%' }} />
</colgroup>
<thead className='shadow-[inset_0_-1px_0_var(--border)]'>
<tr>
{COL_HEADERS.map(({ key, label }) => (
<th
key={key}
className='h-10 px-6 py-1.5 text-left align-middle font-normal text-caption'
>
<button
type='button'
onClick={() => handleSort(key)}
className={cn(
'flex items-center gap-1 transition-colors hover-hover:text-[var(--text-secondary)]',
sortKey === key ? 'text-[var(--text-secondary)]' : 'text-[var(--text-muted)]'
)}
>
{label}
{sortKey === key && <ArrowUpDown className='h-[10px] w-[10px] opacity-60' />}
</button>
</th>
))}
</tr>
</thead>
<tbody>
{sorted.map((log) => (
<tr
key={log.id}
className='h-[44px] cursor-default transition-colors hover-hover:bg-[var(--surface-3)]'
>
<td className='px-6 align-middle'>
<div className='flex items-center gap-2'>
<div
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px] border-[1.5px]'
style={{
backgroundColor: log.workflowColor,
borderColor: `${log.workflowColor}60`,
backgroundClip: 'padding-box',
}}
/>
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-caption'>
{log.workflowName}
</span>
</div>
</td>
<td className='px-6 align-middle text-[var(--text-secondary)] text-caption'>
{log.date}
</td>
<td className='px-6 align-middle'>
<Badge variant={STATUS_VARIANT[log.status]} size='sm' dot>
{STATUS_LABELS[log.status]}
</Badge>
</td>
<td className='px-6 align-middle text-[var(--text-secondary)] text-caption'>
{log.cost}
</td>
<td className='px-6 align-middle'>
<Badge variant={TRIGGER_VARIANT[log.trigger]} size='sm'>
{log.triggerLabel}
</Badge>
</td>
<td className='px-6 align-middle text-[var(--text-secondary)] text-caption'>
{log.duration}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,211 @@
'use client'
import type { ReactNode } from 'react'
import { useMemo, useState } from 'react'
import { ArrowUpDown, ListFilter, Plus, Search } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
export interface PreviewColumn {
id: string
header: string
width?: number
}
export interface PreviewCell {
icon?: ReactNode
label?: string
content?: ReactNode
}
export interface PreviewRow {
id: string
cells: Record<string, PreviewCell>
}
interface LandingPreviewResourceProps {
icon: React.ComponentType<{ className?: string }>
title: string
createLabel: string
searchPlaceholder: string
columns: PreviewColumn[]
rows: PreviewRow[]
onRowClick?: (id: string) => void
}
export function ownerCell(initial: string, name: string): PreviewCell {
return {
icon: (
<span className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-3)] font-medium text-[8px] text-[var(--text-secondary)]'>
{initial}
</span>
),
label: name,
}
}
export function LandingPreviewResource({
icon: Icon,
title,
createLabel,
searchPlaceholder,
columns,
rows,
onRowClick,
}: LandingPreviewResourceProps) {
const [search, setSearch] = useState('')
const [sortColId, setSortColId] = useState<string | null>(null)
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
function handleSortClick(colId: string) {
if (sortColId === colId) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
} else {
setSortColId(colId)
setSortDir('asc')
}
}
const sorted = useMemo(() => {
const q = search.toLowerCase()
const filtered = q
? rows.filter((row) =>
Object.values(row.cells).some((cell) => cell.label?.toLowerCase().includes(q))
)
: rows
if (!sortColId) return filtered
return [...filtered].sort((a, b) => {
const av = a.cells[sortColId]?.label ?? ''
const bv = b.cells[sortColId]?.label ?? ''
const cmp = av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' })
return sortDir === 'asc' ? cmp : -cmp
})
}, [rows, search, sortColId, sortDir])
return (
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
{/* Header */}
<div className='border-[var(--border)] border-b px-6 py-2.5'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-3'>
<Icon className='h-[14px] w-[14px] text-[var(--text-icon)]' />
<h1 className='font-medium text-[var(--text-body)] text-sm'>{title}</h1>
</div>
<div className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption'>
<Plus className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
{createLabel}
</div>
</div>
</div>
{/* Options bar */}
<div className='border-[var(--border)] border-b px-6 py-2.5'>
<div className='flex items-center justify-between'>
<div className='flex flex-1 items-center gap-2.5'>
<Search className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<input
type='text'
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={searchPlaceholder}
className='flex-1 bg-transparent text-[var(--text-body)] text-caption outline-none placeholder:text-[var(--text-subtle)]'
/>
</div>
<div className='flex items-center gap-1.5'>
<div className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption'>
<ListFilter className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
Filter
</div>
<button
type='button'
onClick={() => handleSortClick(sortColId ?? columns[0]?.id)}
className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-3)]'
>
<ArrowUpDown className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
Sort
</button>
</div>
</div>
</div>
{/* Table */}
<div className='min-h-0 flex-1 overflow-hidden'>
<table className='w-full table-fixed text-sm'>
<colgroup>
{columns.map((col, i) => (
<col
key={col.id}
style={i === 0 ? { minWidth: col.width ?? 200 } : { width: col.width ?? 160 }}
/>
))}
</colgroup>
<thead className='shadow-[inset_0_-1px_0_var(--border)]'>
<tr>
{columns.map((col) => (
<th
key={col.id}
className='h-10 px-6 py-1.5 text-left align-middle font-normal text-caption'
>
<button
type='button'
onClick={() => handleSortClick(col.id)}
className={cn(
'flex items-center gap-1 transition-colors hover-hover:text-[var(--text-secondary)]',
sortColId === col.id
? 'text-[var(--text-secondary)]'
: 'text-[var(--text-muted)]'
)}
>
{col.header}
{sortColId === col.id && (
<ArrowUpDown className='h-[10px] w-[10px] opacity-60' />
)}
</button>
</th>
))}
</tr>
</thead>
<tbody>
{sorted.map((row) => (
<tr
key={row.id}
onClick={() => onRowClick?.(row.id)}
className={cn(
'transition-colors hover-hover:bg-[var(--surface-3)]',
onRowClick && 'cursor-pointer'
)}
>
{columns.map((col, colIdx) => {
const cell = row.cells[col.id]
return (
<td key={col.id} className='px-6 py-2.5 align-middle'>
{cell?.content ? (
cell.content
) : (
<span
className={cn(
'flex min-w-0 items-center gap-3 font-medium text-sm',
colIdx === 0
? 'text-[var(--text-body)]'
: 'text-[var(--text-secondary)]'
)}
>
{cell?.icon && (
<span className='flex-shrink-0 text-[var(--text-icon)]'>
{cell.icon}
</span>
)}
<span className='truncate'>{cell?.label ?? '—'}</span>
</span>
)}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,88 @@
import { Calendar } from '@/components/emcn/icons'
import type {
PreviewColumn,
PreviewRow,
} from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
import { LandingPreviewResource } from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
const CAL_ICON = <Calendar className='h-[14px] w-[14px]' />
const COLUMNS: PreviewColumn[] = [
{ id: 'task', header: 'Task' },
{ id: 'schedule', header: 'Schedule', width: 240 },
{ id: 'nextRun', header: 'Next Run' },
{ id: 'lastRun', header: 'Last Run' },
]
const ROWS: PreviewRow[] = [
{
id: '1',
cells: {
task: { icon: CAL_ICON, label: 'Sync CRM contacts' },
schedule: { label: 'Recurring, every day at 9:00 AM' },
nextRun: { label: 'Tomorrow' },
lastRun: { label: '2 hours ago' },
},
},
{
id: '2',
cells: {
task: { icon: CAL_ICON, label: 'Generate weekly report' },
schedule: { label: 'Recurring, every Monday at 8:00 AM' },
nextRun: { label: 'In 5 days' },
lastRun: { label: '6 days ago' },
},
},
{
id: '3',
cells: {
task: { icon: CAL_ICON, label: 'Clean up stale files' },
schedule: { label: 'Recurring, every Sunday at midnight' },
nextRun: { label: 'In 2 days' },
lastRun: { label: '6 days ago' },
},
},
{
id: '4',
cells: {
task: { icon: CAL_ICON, label: 'Send performance digest' },
schedule: { label: 'Recurring, every Friday at 5:00 PM' },
nextRun: { label: 'In 3 days' },
lastRun: { label: '3 days ago' },
},
},
{
id: '5',
cells: {
task: { icon: CAL_ICON, label: 'Backup production data' },
schedule: { label: 'Recurring, every 4 hours' },
nextRun: { label: 'In 2 hours' },
lastRun: { label: '2 hours ago' },
},
},
{
id: '6',
cells: {
task: { icon: CAL_ICON, label: 'Scrape competitor pricing' },
schedule: { label: 'Recurring, every Tuesday at 6:00 AM' },
nextRun: { label: 'In 6 days' },
lastRun: { label: '1 week ago' },
},
},
]
/**
* Static landing preview of the Scheduled Tasks workspace page.
*/
export function LandingPreviewScheduledTasks() {
return (
<LandingPreviewResource
icon={Calendar}
title='Scheduled Tasks'
createLabel='New scheduled task'
searchPlaceholder='Search scheduled tasks...'
columns={COLUMNS}
rows={ROWS}
/>
)
}

View File

@@ -10,14 +10,25 @@ import {
Settings,
Table,
} from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import type { PreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
export type SidebarView =
| 'home'
| 'workflow'
| 'tables'
| 'files'
| 'knowledge'
| 'logs'
| 'scheduled-tasks'
interface LandingPreviewSidebarProps {
workflows: PreviewWorkflow[]
activeWorkflowId: string
activeView: 'home' | 'workflow'
activeView: SidebarView
onSelectWorkflow: (id: string) => void
onSelectHome: () => void
onSelectNav: (id: SidebarView) => void
}
/**
@@ -39,7 +50,7 @@ const C = {
const WORKSPACE_NAV = [
{ id: 'tables', label: 'Tables', icon: Table },
{ id: 'files', label: 'Files', icon: File },
{ id: 'knowledge-base', label: 'Knowledge Base', icon: Database },
{ id: 'knowledge', label: 'Knowledge Base', icon: Database },
{ id: 'scheduled-tasks', label: 'Scheduled Tasks', icon: Calendar },
{ id: 'logs', label: 'Logs', icon: Library },
] as const
@@ -49,20 +60,42 @@ const FOOTER_NAV = [
{ id: 'settings', label: 'Settings', icon: Settings },
] as const
function StaticNavItem({
function NavItem({
icon: Icon,
label,
isActive,
onClick,
}: {
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>
label: string
isActive?: boolean
onClick?: () => void
}) {
if (!onClick) {
return (
<div className='pointer-events-none mx-0.5 flex h-[28px] items-center gap-2 rounded-[8px] px-2'>
<Icon className='h-[14px] w-[14px] flex-shrink-0' style={{ color: C.TEXT_ICON }} />
<span className='truncate text-[13px]' style={{ color: C.TEXT_BODY, fontWeight: 450 }}>
{label}
</span>
</div>
)
}
return (
<div className='pointer-events-none mx-0.5 flex h-[28px] items-center gap-2 rounded-[8px] px-2'>
<button
type='button'
onClick={onClick}
className={cn(
'mx-0.5 flex h-[28px] items-center gap-2 rounded-[8px] px-2 transition-colors hover-hover:bg-[var(--c-active)]',
isActive && 'bg-[var(--c-active)]'
)}
>
<Icon className='h-[14px] w-[14px] flex-shrink-0' style={{ color: C.TEXT_ICON }} />
<span className='truncate text-[13px]' style={{ color: C.TEXT_BODY, fontWeight: 450 }}>
{label}
</span>
</div>
</button>
)
}
@@ -77,13 +110,16 @@ export function LandingPreviewSidebar({
activeView,
onSelectWorkflow,
onSelectHome,
onSelectNav,
}: LandingPreviewSidebarProps) {
const isHomeActive = activeView === 'home'
return (
<div
className='flex h-full w-[248px] flex-shrink-0 flex-col pt-3'
style={{ backgroundColor: C.SURFACE_1 }}
style={
{ backgroundColor: C.SURFACE_1, '--c-active': C.SURFACE_ACTIVE } as React.CSSProperties
}
>
{/* Workspace Header */}
<div className='flex-shrink-0 px-2.5'>
@@ -116,21 +152,17 @@ export function LandingPreviewSidebar({
<button
type='button'
onClick={onSelectHome}
className='mx-0.5 flex h-[28px] items-center gap-2 rounded-[8px] px-2 transition-colors'
style={{ backgroundColor: isHomeActive ? C.SURFACE_ACTIVE : 'transparent' }}
onMouseEnter={(e) => {
if (!isHomeActive) e.currentTarget.style.backgroundColor = C.SURFACE_ACTIVE
}}
onMouseLeave={(e) => {
if (!isHomeActive) e.currentTarget.style.backgroundColor = 'transparent'
}}
className={cn(
'mx-0.5 flex h-[28px] items-center gap-2 rounded-[8px] px-2 transition-colors hover-hover:bg-[var(--c-active)]',
isHomeActive && 'bg-[var(--c-active)]'
)}
>
<Home className='h-[14px] w-[14px] flex-shrink-0' style={{ color: C.TEXT_ICON }} />
<span className='truncate text-[13px]' style={{ color: C.TEXT_BODY, fontWeight: 450 }}>
Home
</span>
</button>
<StaticNavItem icon={Search} label='Search' />
<NavItem icon={Search} label='Search' />
</div>
{/* Workspace */}
@@ -142,7 +174,13 @@ export function LandingPreviewSidebar({
</div>
<div className='flex flex-col gap-0.5 px-2'>
{WORKSPACE_NAV.map((item) => (
<StaticNavItem key={item.id} icon={item.icon} label={item.label} />
<NavItem
key={item.id}
icon={item.icon}
label={item.label}
isActive={activeView === item.id}
onClick={() => onSelectNav(item.id)}
/>
))}
</div>
</div>
@@ -164,14 +202,10 @@ export function LandingPreviewSidebar({
key={workflow.id}
type='button'
onClick={() => onSelectWorkflow(workflow.id)}
className='group mx-0.5 flex h-[28px] w-full items-center gap-2 rounded-[8px] px-2 transition-colors'
style={{ backgroundColor: isActive ? C.SURFACE_ACTIVE : 'transparent' }}
onMouseEnter={(e) => {
if (!isActive) e.currentTarget.style.backgroundColor = C.SURFACE_ACTIVE
}}
onMouseLeave={(e) => {
if (!isActive) e.currentTarget.style.backgroundColor = 'transparent'
}}
className={cn(
'mx-0.5 flex h-[28px] w-full items-center gap-2 rounded-[8px] px-2 transition-colors hover-hover:bg-[#363636]',
isActive && 'bg-[#363636]'
)}
>
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded-[4px] border-[2.5px]'
@@ -197,7 +231,7 @@ export function LandingPreviewSidebar({
{/* Footer */}
<div className='flex flex-shrink-0 flex-col gap-0.5 px-2 pt-[9px] pb-2'>
{FOOTER_NAV.map((item) => (
<StaticNavItem key={item.id} icon={item.icon} label={item.label} />
<NavItem key={item.id} icon={item.icon} label={item.label} />
))}
</div>
</div>

View File

@@ -0,0 +1,552 @@
'use client'
import { useState } from 'react'
import { Checkbox } from '@/components/emcn'
import {
ChevronDown,
Columns3,
Rows3,
Table,
TypeBoolean,
TypeNumber,
TypeText,
} from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import type {
PreviewColumn,
PreviewRow,
} from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
import {
LandingPreviewResource,
ownerCell,
} from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
const CELL = 'border-[var(--border)] border-r border-b px-2 py-[7px] align-middle select-none'
const CELL_CHECKBOX =
'border-[var(--border)] border-r border-b px-1 py-[7px] align-middle select-none'
const CELL_HEADER =
'border-[var(--border)] border-r border-b bg-[var(--bg)] p-0 text-left align-middle'
const CELL_HEADER_CHECKBOX =
'border-[var(--border)] border-r border-b bg-[var(--bg)] px-1 py-[7px] text-center align-middle'
const CELL_CONTENT =
'relative min-h-[20px] min-w-0 overflow-clip text-ellipsis whitespace-nowrap text-small'
const SELECTION_OVERLAY =
'pointer-events-none absolute -top-px -right-px -bottom-px -left-px z-[5] border-[2px] border-[var(--selection)]'
const LIST_COLUMNS: PreviewColumn[] = [
{ id: 'name', header: 'Name' },
{ id: 'columns', header: 'Columns' },
{ id: 'rows', header: 'Rows' },
{ id: 'created', header: 'Created' },
{ id: 'owner', header: 'Owner' },
]
const TABLE_METAS: Record<string, string> = {
'1': 'Customer Leads',
'2': 'Product Catalog',
'3': 'Campaign Analytics',
'4': 'User Profiles',
'5': 'Invoice Records',
}
const TABLE_ICON = <Table className='h-[14px] w-[14px]' />
const COLUMNS_ICON = <Columns3 className='h-[14px] w-[14px]' />
const ROWS_ICON = <Rows3 className='h-[14px] w-[14px]' />
const LIST_ROWS: PreviewRow[] = [
{
id: '1',
cells: {
name: { icon: TABLE_ICON, label: 'Customer Leads' },
columns: { icon: COLUMNS_ICON, label: '8' },
rows: { icon: ROWS_ICON, label: '2,847' },
created: { label: '2 days ago' },
owner: ownerCell('S', 'Sarah K.'),
},
},
{
id: '2',
cells: {
name: { icon: TABLE_ICON, label: 'Product Catalog' },
columns: { icon: COLUMNS_ICON, label: '12' },
rows: { icon: ROWS_ICON, label: '1,203' },
created: { label: '5 days ago' },
owner: ownerCell('A', 'Alex M.'),
},
},
{
id: '3',
cells: {
name: { icon: TABLE_ICON, label: 'Campaign Analytics' },
columns: { icon: COLUMNS_ICON, label: '6' },
rows: { icon: ROWS_ICON, label: '534' },
created: { label: '1 week ago' },
owner: ownerCell('W', 'Emaan K.'),
},
},
{
id: '4',
cells: {
name: { icon: TABLE_ICON, label: 'User Profiles' },
columns: { icon: COLUMNS_ICON, label: '15' },
rows: { icon: ROWS_ICON, label: '18,492' },
created: { label: '2 weeks ago' },
owner: ownerCell('J', 'Jordan P.'),
},
},
{
id: '5',
cells: {
name: { icon: TABLE_ICON, label: 'Invoice Records' },
columns: { icon: COLUMNS_ICON, label: '9' },
rows: { icon: ROWS_ICON, label: '742' },
created: { label: 'March 15th, 2026' },
owner: ownerCell('S', 'Sarah K.'),
},
},
]
interface SpreadsheetColumn {
id: string
label: string
type: 'text' | 'number' | 'boolean'
width: number
}
interface SpreadsheetRow {
id: string
cells: Record<string, string>
}
const COLUMN_TYPE_ICONS = {
text: TypeText,
number: TypeNumber,
boolean: TypeBoolean,
} as const
const SPREADSHEET_DATA: Record<string, { columns: SpreadsheetColumn[]; rows: SpreadsheetRow[] }> = {
'1': {
columns: [
{ id: 'name', label: 'Name', type: 'text', width: 160 },
{ id: 'email', label: 'Email', type: 'text', width: 200 },
{ id: 'company', label: 'Company', type: 'text', width: 160 },
{ id: 'score', label: 'Score', type: 'number', width: 100 },
{ id: 'qualified', label: 'Qualified', type: 'boolean', width: 120 },
],
rows: [
{
id: '1',
cells: {
name: 'Alice Johnson',
email: 'alice@acme.com',
company: 'Acme Corp',
score: '87',
qualified: 'true',
},
},
{
id: '2',
cells: {
name: 'Bob Williams',
email: 'bob@techco.io',
company: 'TechCo',
score: '62',
qualified: 'false',
},
},
{
id: '3',
cells: {
name: 'Carol Davis',
email: 'carol@startup.co',
company: 'StartupCo',
score: '94',
qualified: 'true',
},
},
{
id: '4',
cells: {
name: 'Dan Miller',
email: 'dan@bigcorp.com',
company: 'BigCorp',
score: '71',
qualified: 'true',
},
},
{
id: '5',
cells: {
name: 'Eva Chen',
email: 'eva@design.io',
company: 'Design IO',
score: '45',
qualified: 'false',
},
},
{
id: '6',
cells: {
name: 'Frank Lee',
email: 'frank@ventures.co',
company: 'Ventures',
score: '88',
qualified: 'true',
},
},
],
},
'2': {
columns: [
{ id: 'sku', label: 'SKU', type: 'text', width: 120 },
{ id: 'name', label: 'Product Name', type: 'text', width: 200 },
{ id: 'price', label: 'Price', type: 'number', width: 100 },
{ id: 'stock', label: 'In Stock', type: 'number', width: 120 },
{ id: 'active', label: 'Active', type: 'boolean', width: 90 },
],
rows: [
{
id: '1',
cells: {
sku: 'PRD-001',
name: 'Wireless Headphones',
price: '79.99',
stock: '234',
active: 'true',
},
},
{
id: '2',
cells: { sku: 'PRD-002', name: 'USB-C Hub', price: '49.99', stock: '89', active: 'true' },
},
{
id: '3',
cells: {
sku: 'PRD-003',
name: 'Laptop Stand',
price: '39.99',
stock: '0',
active: 'false',
},
},
{
id: '4',
cells: {
sku: 'PRD-004',
name: 'Mechanical Keyboard',
price: '129.99',
stock: '52',
active: 'true',
},
},
{
id: '5',
cells: { sku: 'PRD-005', name: 'Webcam HD', price: '89.99', stock: '17', active: 'true' },
},
{
id: '6',
cells: {
sku: 'PRD-006',
name: 'Mouse Pad XL',
price: '24.99',
stock: '0',
active: 'false',
},
},
],
},
'3': {
columns: [
{ id: 'campaign', label: 'Campaign', type: 'text', width: 180 },
{ id: 'clicks', label: 'Clicks', type: 'number', width: 100 },
{ id: 'conversions', label: 'Conversions', type: 'number', width: 140 },
{ id: 'spend', label: 'Spend ($)', type: 'number', width: 130 },
{ id: 'active', label: 'Active', type: 'boolean', width: 90 },
],
rows: [
{
id: '1',
cells: {
campaign: 'Spring Sale 2026',
clicks: '12,847',
conversions: '384',
spend: '2,400',
active: 'true',
},
},
{
id: '2',
cells: {
campaign: 'Email Reactivation',
clicks: '3,201',
conversions: '97',
spend: '450',
active: 'false',
},
},
{
id: '3',
cells: {
campaign: 'Referral Program',
clicks: '8,923',
conversions: '210',
spend: '1,100',
active: 'true',
},
},
{
id: '4',
cells: {
campaign: 'Product Launch',
clicks: '24,503',
conversions: '891',
spend: '5,800',
active: 'true',
},
},
{
id: '5',
cells: {
campaign: 'Retargeting Q1',
clicks: '6,712',
conversions: '143',
spend: '980',
active: 'false',
},
},
],
},
'4': {
columns: [
{ id: 'username', label: 'Username', type: 'text', width: 140 },
{ id: 'email', label: 'Email', type: 'text', width: 200 },
{ id: 'plan', label: 'Plan', type: 'text', width: 120 },
{ id: 'seats', label: 'Seats', type: 'number', width: 100 },
{ id: 'active', label: 'Active', type: 'boolean', width: 100 },
],
rows: [
{
id: '1',
cells: {
username: 'alice_j',
email: 'alice@acme.com',
plan: 'Pro',
seats: '5',
active: 'true',
},
},
{
id: '2',
cells: {
username: 'bobw',
email: 'bob@techco.io',
plan: 'Starter',
seats: '1',
active: 'true',
},
},
{
id: '3',
cells: {
username: 'carol_d',
email: 'carol@startup.co',
plan: 'Enterprise',
seats: '25',
active: 'true',
},
},
{
id: '4',
cells: {
username: 'dan.m',
email: 'dan@bigcorp.com',
plan: 'Pro',
seats: '10',
active: 'false',
},
},
{
id: '5',
cells: {
username: 'eva_chen',
email: 'eva@design.io',
plan: 'Starter',
seats: '1',
active: 'true',
},
},
{
id: '6',
cells: {
username: 'frank_lee',
email: 'frank@ventures.co',
plan: 'Enterprise',
seats: '50',
active: 'true',
},
},
],
},
'5': {
columns: [
{ id: 'invoice', label: 'Invoice #', type: 'text', width: 140 },
{ id: 'client', label: 'Client', type: 'text', width: 160 },
{ id: 'amount', label: 'Amount ($)', type: 'number', width: 130 },
{ id: 'paid', label: 'Paid', type: 'boolean', width: 80 },
],
rows: [
{
id: '1',
cells: { invoice: 'INV-2026-001', client: 'Acme Corp', amount: '4,800.00', paid: 'true' },
},
{
id: '2',
cells: { invoice: 'INV-2026-002', client: 'TechCo', amount: '1,200.00', paid: 'true' },
},
{
id: '3',
cells: { invoice: 'INV-2026-003', client: 'StartupCo', amount: '750.00', paid: 'false' },
},
{
id: '4',
cells: { invoice: 'INV-2026-004', client: 'BigCorp', amount: '12,500.00', paid: 'true' },
},
{
id: '5',
cells: { invoice: 'INV-2026-005', client: 'Design IO', amount: '3,300.00', paid: 'false' },
},
],
},
}
interface SpreadsheetViewProps {
tableId: string
tableName: string
onBack: () => void
}
function SpreadsheetView({ tableId, tableName, onBack }: SpreadsheetViewProps) {
const data = SPREADSHEET_DATA[tableId] ?? SPREADSHEET_DATA['1']
const [selectedCell, setSelectedCell] = useState<{ row: string; col: string } | null>(null)
return (
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
{/* Breadcrumb header — matches real ResourceHeader breadcrumb layout */}
<div className='border-[var(--border)] border-b px-4 py-[8.5px]'>
<div className='flex items-center gap-3'>
<button
type='button'
onClick={onBack}
className='inline-flex items-center px-2 py-1 font-medium text-[var(--text-secondary)] text-sm transition-colors hover-hover:text-[var(--text-body)]'
>
<Table className='mr-3 h-[14px] w-[14px] text-[var(--text-icon)]' />
Tables
</button>
<span className='select-none text-[var(--text-icon)] text-sm'>/</span>
<span className='inline-flex items-center px-2 py-1 font-medium text-[var(--text-body)] text-sm'>
{tableName}
<ChevronDown className='ml-2 h-[7px] w-[9px] shrink-0 text-[var(--text-muted)]' />
</span>
</div>
</div>
{/* Spreadsheet — matches exact real table editor structure */}
<div className='min-h-0 flex-1 overflow-auto overscroll-none'>
<table className='table-fixed border-separate border-spacing-0 text-small'>
<colgroup>
<col style={{ width: 40 }} />
{data.columns.map((col) => (
<col key={col.id} style={{ width: col.width }} />
))}
</colgroup>
<thead className='sticky top-0 z-10'>
<tr>
<th className={CELL_HEADER_CHECKBOX} />
{data.columns.map((col) => {
const Icon = COLUMN_TYPE_ICONS[col.type] ?? TypeText
return (
<th key={col.id} className={CELL_HEADER}>
<div className='flex h-full w-full min-w-0 items-center px-2 py-[7px]'>
<Icon className='h-3 w-3 shrink-0 text-[var(--text-icon)]' />
<span className='ml-1.5 min-w-0 overflow-clip text-ellipsis whitespace-nowrap font-medium text-[var(--text-primary)] text-small'>
{col.label}
</span>
<ChevronDown className='ml-auto h-[7px] w-[9px] shrink-0 text-[var(--text-muted)]' />
</div>
</th>
)
})}
</tr>
</thead>
<tbody>
{data.rows.map((row, rowIdx) => (
<tr key={row.id}>
<td className={cn(CELL_CHECKBOX, 'text-center')}>
<span className='text-[var(--text-tertiary)] text-xs tabular-nums'>
{rowIdx + 1}
</span>
</td>
{data.columns.map((col) => {
const isSelected = selectedCell?.row === row.id && selectedCell?.col === col.id
const cellValue = row.cells[col.id] ?? ''
return (
<td
key={col.id}
onClick={() => setSelectedCell({ row: row.id, col: col.id })}
className={cn(
CELL,
'relative cursor-default text-[var(--text-body)]',
isSelected && 'bg-[rgba(37,99,235,0.06)]'
)}
>
{isSelected && <div className={SELECTION_OVERLAY} />}
<div className={CELL_CONTENT}>
{col.type === 'boolean' ? (
<div className='flex min-h-[20px] items-center justify-center'>
<Checkbox
size='sm'
checked={cellValue === 'true'}
className='pointer-events-none'
/>
</div>
) : (
cellValue
)}
</div>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
export function LandingPreviewTables() {
const [openTableId, setOpenTableId] = useState<string | null>(null)
if (openTableId !== null) {
return (
<SpreadsheetView
tableId={openTableId}
tableName={TABLE_METAS[openTableId] ?? 'Table'}
onBack={() => setOpenTableId(null)}
/>
)
}
return (
<LandingPreviewResource
icon={Table}
title='Tables'
createLabel='New table'
searchPlaceholder='Search tables...'
columns={LIST_COLUMNS}
rows={LIST_ROWS}
onRowClick={(id) => setOpenTableId(id)}
/>
)
}

View File

@@ -228,13 +228,13 @@ export const PreviewBlockNode = memo(function PreviewBlockNode({
{tools && tools.length > 0 && (
<div className='flex items-center gap-2'>
<span className='flex-shrink-0 font-normal text-[#b3b3b3] text-[14px]'>Tools</span>
<div className='flex flex-1 flex-wrap items-center justify-end gap-2'>
<div className='flex flex-1 flex-wrap items-center justify-end gap-[5px]'>
{tools.map((tool) => {
const ToolIcon = BLOCK_ICONS[tool.type]
return (
<div
key={tool.type}
className='flex items-center gap-2 rounded-[5px] border border-[#3d3d3d] bg-[#2a2a2a] px-2 py-1'
className='flex items-center gap-[5px] rounded-[5px] border border-[#3d3d3d] bg-[#2a2a2a] px-[6px] py-[3px]'
>
<div
className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px]'

View File

@@ -127,6 +127,60 @@ const SELF_HEALING_CRM_WORKFLOW: PreviewWorkflow = {
edges: [{ id: 'e-3', source: 'schedule-1', target: 'mothership-1' }],
}
/**
* Customer Support Agent workflow — Gmail Trigger -> Agent (KB + Notion tools) -> Slack
*/
const CUSTOMER_SUPPORT_WORKFLOW: PreviewWorkflow = {
id: 'wf-customer-support',
name: 'Customer Support Agent',
color: '#0EA5E9',
blocks: [
{
id: 'gmail-1',
name: 'Gmail',
type: 'gmail',
bgColor: '#E0E0E0',
rows: [
{ title: 'Event', value: 'New Email' },
{ title: 'Label', value: 'Support' },
],
position: { x: 80, y: 140 },
hideTargetHandle: true,
},
{
id: 'agent-3',
name: 'Support Agent',
type: 'agent',
bgColor: '#701ffc',
rows: [
{ title: 'Model', value: 'gpt-5.4' },
{ title: 'System Prompt', value: 'Resolve customer issues...' },
],
tools: [
{ name: 'Knowledge', type: 'knowledge_base', bgColor: '#10B981' },
{ name: 'Notion', type: 'notion', bgColor: '#181C1E' },
],
position: { x: 420, y: 40 },
},
{
id: 'slack-3',
name: 'Slack',
type: 'slack',
bgColor: '#611f69',
rows: [
{ title: 'Channel', value: '#support' },
{ title: 'Operation', value: 'Send Message' },
],
position: { x: 420, y: 260 },
hideSourceHandle: true,
},
],
edges: [
{ id: 'e-cs-1', source: 'gmail-1', target: 'agent-3' },
{ id: 'e-cs-2', source: 'gmail-1', target: 'slack-3' },
],
}
/**
* Empty "New Agent" workflow — a single note prompting the user to start building
*/
@@ -153,6 +207,7 @@ const NEW_AGENT_WORKFLOW: PreviewWorkflow = {
export const PREVIEW_WORKFLOWS: PreviewWorkflow[] = [
SELF_HEALING_CRM_WORKFLOW,
IT_SERVICE_WORKFLOW,
CUSTOMER_SUPPORT_WORKFLOW,
NEW_AGENT_WORKFLOW,
]

View File

@@ -2,9 +2,15 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { motion, type Variants } from 'framer-motion'
import { LandingPreviewFiles } from '@/app/(home)/components/landing-preview/components/landing-preview-files/landing-preview-files'
import { LandingPreviewHome } from '@/app/(home)/components/landing-preview/components/landing-preview-home/landing-preview-home'
import { LandingPreviewKnowledge } from '@/app/(home)/components/landing-preview/components/landing-preview-knowledge/landing-preview-knowledge'
import { LandingPreviewLogs } from '@/app/(home)/components/landing-preview/components/landing-preview-logs/landing-preview-logs'
import { LandingPreviewPanel } from '@/app/(home)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
import { LandingPreviewScheduledTasks } from '@/app/(home)/components/landing-preview/components/landing-preview-scheduled-tasks/landing-preview-scheduled-tasks'
import type { SidebarView } from '@/app/(home)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
import { LandingPreviewSidebar } from '@/app/(home)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
import { LandingPreviewTables } from '@/app/(home)/components/landing-preview/components/landing-preview-tables/landing-preview-tables'
import { LandingPreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/landing-preview-workflow'
import {
EASE_OUT,
@@ -46,18 +52,16 @@ const panelVariants: Variants = {
* Interactive workspace preview for the hero section.
*
* Renders a lightweight replica of the Sim workspace with:
* - A sidebar with two selectable workflows
* - A sidebar with selectable workflows and workspace nav items
* - A ReactFlow canvas showing the active workflow's blocks and edges
* - Static previews of Tables, Files, Knowledge Base, Logs, and Scheduled Tasks
* - A panel with a functional copilot input (stores prompt + redirects to /signup)
*
* Everything except the workflow items and the copilot input is non-interactive.
* On mount the sidebar slides from left and the panel from right. The canvas
* background stays fully opaque; individual block nodes animate in with a
* staggered fade. Edges draw left-to-right. Animations only fire on initial
* load — workflow switches render instantly.
* Only workflow items, the home button, workspace nav items, and the copilot input
* are interactive. Animations only fire on initial load.
*/
export function LandingPreview() {
const [activeView, setActiveView] = useState<'home' | 'workflow'>('workflow')
const [activeView, setActiveView] = useState<SidebarView>('workflow')
const [activeWorkflowId, setActiveWorkflowId] = useState(PREVIEW_WORKFLOWS[0].id)
const isInitialMount = useRef(true)
@@ -74,11 +78,34 @@ export function LandingPreview() {
setActiveView('home')
}, [])
const handleSelectNav = useCallback((id: SidebarView) => {
setActiveView(id)
}, [])
const activeWorkflow =
PREVIEW_WORKFLOWS.find((w) => w.id === activeWorkflowId) ?? PREVIEW_WORKFLOWS[0]
const isWorkflowView = activeView === 'workflow'
function renderContent() {
switch (activeView) {
case 'workflow':
return <LandingPreviewWorkflow workflow={activeWorkflow} animate={isInitialMount.current} />
case 'home':
return <LandingPreviewHome />
case 'tables':
return <LandingPreviewTables />
case 'files':
return <LandingPreviewFiles />
case 'knowledge':
return <LandingPreviewKnowledge />
case 'logs':
return <LandingPreviewLogs />
case 'scheduled-tasks':
return <LandingPreviewScheduledTasks />
}
}
return (
<motion.div
className='dark flex aspect-[1116/549] w-full overflow-hidden rounded bg-[var(--landing-bg-surface)] antialiased'
@@ -93,6 +120,7 @@ export function LandingPreview() {
activeView={activeView}
onSelectWorkflow={handleSelectWorkflow}
onSelectHome={handleSelectHome}
onSelectNav={handleSelectNav}
/>
</motion.div>
<div className='flex min-w-0 flex-1 flex-col py-2 pr-2 pl-2 lg:pl-0'>
@@ -104,11 +132,7 @@ export function LandingPreview() {
: 'relative flex min-w-0 flex-1 flex-col overflow-hidden'
}
>
{isWorkflowView ? (
<LandingPreviewWorkflow workflow={activeWorkflow} animate={isInitialMount.current} />
) : (
<LandingPreviewHome />
)}
{renderContent()}
</div>
<motion.div
className={isWorkflowView ? 'hidden lg:flex' : 'hidden'}

View File

@@ -80,7 +80,7 @@ const PRICING_TIERS: PricingTier[] = [
'Custom execution limits',
'Custom concurrency limits',
'Unlimited log retention',
'SSO & SCIM · SOC2 & HIPAA',
'SSO & SCIM · SOC2',
'Self hosting · Dedicated support',
],
cta: { label: 'Book a demo', action: 'demo-request' },

View File

@@ -93,7 +93,7 @@ export default function StructuredData() {
url: 'https://sim.ai',
name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
description:
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 and HIPAA compliant.',
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 compliant.',
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Web',
browserRequirements: 'Requires a modern browser with JavaScript enabled',
@@ -179,7 +179,7 @@ export default function StructuredData() {
name: 'What is Sim?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Sim is the open-source platform to build AI agents and run your agentic workforce. Teams connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 and HIPAA compliant.',
text: 'Sim is the open-source platform to build AI agents and run your agentic workforce. Teams connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 compliant.',
},
},
{
@@ -211,7 +211,7 @@ export default function StructuredData() {
name: 'What enterprise features does Sim offer?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Sim offers SOC2 and HIPAA compliance, SSO/SAML authentication, role-based access control, audit logs, dedicated support, custom SLAs, and on-premise deployment options for enterprise customers.',
text: 'Sim offers SOC2 compliance, SSO/SAML authentication, role-based access control, audit logs, dedicated support, custom SLAs, and on-premise deployment options for enterprise customers.',
},
},
],

View File

@@ -8,6 +8,8 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/emcn'
import { Copy } from '@/components/emcn/icons'
import { LinkedInIcon, xIcon as XIcon } from '@/components/icons'
interface ShareButtonProps {
url: string
@@ -50,10 +52,17 @@ export function ShareButton({ url, title }: ShareButtonProps) {
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem onSelect={handleCopyLink}>
<Copy className='h-4 w-4' />
{copied ? 'Copied!' : 'Copy link'}
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleShareTwitter}>Share on X</DropdownMenuItem>
<DropdownMenuItem onSelect={handleShareLinkedIn}>Share on LinkedIn</DropdownMenuItem>
<DropdownMenuItem onSelect={handleShareTwitter}>
<XIcon className='h-4 w-4' />
Share on X
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleShareLinkedIn}>
<LinkedInIcon className='h-4 w-4' />
Share on LinkedIn
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)

View File

@@ -0,0 +1,43 @@
'use client'
import { useState } from 'react'
import NextImage from 'next/image'
import { cn } from '@/lib/core/utils/cn'
import { Lightbox } from '@/app/(landing)/blog/components/lightbox'
interface BlogImageProps {
src: string
alt?: string
width?: number
height?: number
className?: string
}
export function BlogImage({ src, alt = '', width = 800, height = 450, className }: BlogImageProps) {
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
return (
<>
<NextImage
src={src}
alt={alt}
width={width}
height={height}
className={cn(
'h-auto w-full cursor-pointer rounded-lg transition-opacity hover:opacity-95',
className
)}
sizes='(max-width: 768px) 100vw, 800px'
loading='lazy'
unoptimized
onClick={() => setIsLightboxOpen(true)}
/>
<Lightbox
isOpen={isLightboxOpen}
onClose={() => setIsLightboxOpen(false)}
src={src}
alt={alt}
/>
</>
)
}

View File

@@ -0,0 +1,62 @@
'use client'
import { useEffect, useRef } from 'react'
interface LightboxProps {
isOpen: boolean
onClose: () => void
src: string
alt: string
}
export function Lightbox({ isOpen, onClose, src, alt }: LightboxProps) {
const overlayRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!isOpen) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose()
}
}
const handleClickOutside = (event: MouseEvent) => {
if (overlayRef.current && event.target === overlayRef.current) {
onClose()
}
}
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('click', handleClickOutside)
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('click', handleClickOutside)
document.body.style.overflow = 'unset'
}
}, [isOpen, onClose])
if (!isOpen) return null
return (
<div
ref={overlayRef}
className='fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-12 backdrop-blur-sm'
role='dialog'
aria-modal='true'
aria-label='Image viewer'
>
<div className='relative max-h-full max-w-full overflow-hidden rounded-xl shadow-2xl'>
<img
src={src}
alt={alt}
className='max-h-[75vh] max-w-[75vw] cursor-pointer rounded-xl object-contain'
loading='lazy'
onClick={onClose}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,63 @@
'use client'
import { useState } from 'react'
import { ChevronDown } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
export interface LandingFAQItem {
question: string
answer: string
}
interface LandingFAQProps {
faqs: LandingFAQItem[]
}
export function LandingFAQ({ faqs }: LandingFAQProps) {
const [openIndex, setOpenIndex] = useState<number | null>(0)
return (
<div className='divide-y divide-[var(--landing-border)]'>
{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-[var(--landing-text)]'
: 'text-[var(--landing-text-muted)] hover:text-[var(--landing-text)]'
)}
>
{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-[14px] text-[var(--landing-text-muted)] leading-[1.75]'>
{answer}
</p>
</div>
)}
</div>
)
})}
</div>
)
}

View File

@@ -1,8 +1,4 @@
'use client'
import { useState } from 'react'
import { ChevronDown } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
import type { FAQItem } from '@/app/(landing)/integrations/data/types'
interface IntegrationFAQProps {
@@ -10,49 +6,5 @@ interface IntegrationFAQProps {
}
export function IntegrationFAQ({ faqs }: IntegrationFAQProps) {
const [openIndex, setOpenIndex] = useState<number | null>(0)
return (
<div className='divide-y divide-[var(--landing-border)]'>
{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-[var(--landing-text)]'
: 'text-[var(--landing-text-muted)] hover:text-[var(--landing-text)]'
)}
>
{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-[14px] text-[var(--landing-text-muted)] leading-[1.75]'>
{answer}
</p>
</div>
)}
</div>
)
})}
</div>
)
return <LandingFAQ faqs={faqs} />
}

View File

@@ -5,6 +5,7 @@
import type { ComponentType, SVGProps } from 'react'
import {
A2AIcon,
AgentMailIcon,
AhrefsIcon,
AirtableIcon,
AirweaveIcon,
@@ -45,6 +46,7 @@ import {
EnrichSoIcon,
EvernoteIcon,
ExaAIIcon,
ExtendIcon,
EyeIcon,
FathomIcon,
FirecrawlIcon,
@@ -91,6 +93,7 @@ import {
KalshiIcon,
KetchIcon,
LangsmithIcon,
LaunchDarklyIcon,
LemlistIcon,
LinearIcon,
LinkedInIcon,
@@ -126,6 +129,7 @@ import {
PolymarketIcon,
PostgresIcon,
PosthogIcon,
ProfoundIcon,
PulseIcon,
QdrantIcon,
QuiverIcon,
@@ -136,9 +140,11 @@ import {
ResendIcon,
RevenueCatIcon,
RipplingIcon,
RootlyIcon,
S3Icon,
SalesforceIcon,
SearchIcon,
SecretsManagerIcon,
SendgridIcon,
SentryIcon,
SerperIcon,
@@ -154,6 +160,7 @@ import {
StagehandIcon,
StripeIcon,
SupabaseIcon,
TailscaleIcon,
TavilyIcon,
TelegramIcon,
TextractIcon,
@@ -183,6 +190,7 @@ type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
export const blockTypeToIconMap: Record<string, IconComponent> = {
a2a: A2AIcon,
agentmail: AgentMailIcon,
ahrefs: AhrefsIcon,
airtable: AirtableIcon,
airweave: AirweaveIcon,
@@ -220,6 +228,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
enrich: EnrichSoIcon,
evernote: EvernoteIcon,
exa: ExaAIIcon,
extend_v2: ExtendIcon,
fathom: FathomIcon,
file_v3: DocumentIcon,
firecrawl: FirecrawlIcon,
@@ -268,6 +277,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
ketch: KetchIcon,
knowledge: PackageSearchIcon,
langsmith: LangsmithIcon,
launchdarkly: LaunchDarklyIcon,
lemlist: LemlistIcon,
linear: LinearIcon,
linkedin: LinkedInIcon,
@@ -302,6 +312,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
polymarket: PolymarketIcon,
postgresql: PostgresIcon,
posthog: PosthogIcon,
profound: ProfoundIcon,
pulse_v2: PulseIcon,
qdrant: QdrantIcon,
quiver: QuiverIcon,
@@ -312,9 +323,11 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
resend: ResendIcon,
revenuecat: RevenueCatIcon,
rippling: RipplingIcon,
rootly: RootlyIcon,
s3: S3Icon,
salesforce: SalesforceIcon,
search: SearchIcon,
secrets_manager: SecretsManagerIcon,
sendgrid: SendgridIcon,
sentry: SentryIcon,
serper: SerperIcon,
@@ -331,6 +344,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
stripe: StripeIcon,
stt_v2: STTIcon,
supabase: SupabaseIcon,
tailscale: TailscaleIcon,
tavily: TavilyIcon,
telegram: TelegramIcon,
textract_v2: TextractIcon,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
import { notFound } from 'next/navigation'
import { createModelsOgImage } from '@/app/(landing)/models/og-utils'
import {
formatPrice,
formatTokenCount,
getModelBySlug,
getProviderBySlug,
} from '@/app/(landing)/models/utils'
export const runtime = 'edge'
export const contentType = 'image/png'
export const size = {
width: 1200,
height: 630,
}
export default async function Image({
params,
}: {
params: Promise<{ provider: string; model: string }>
}) {
const { provider: providerSlug, model: modelSlug } = await params
const provider = getProviderBySlug(providerSlug)
const model = getModelBySlug(providerSlug, modelSlug)
if (!provider || !model) {
notFound()
}
return createModelsOgImage({
eyebrow: `${provider.name} model`,
title: model.displayName,
subtitle: `${provider.name} pricing, context window, and feature support generated from Sim's model registry.`,
pills: [
`Input ${formatPrice(model.pricing.input)}/1M`,
`Output ${formatPrice(model.pricing.output)}/1M`,
model.contextWindow ? `${formatTokenCount(model.contextWindow)} context` : 'Unknown context',
model.capabilityTags[0] ?? 'Capabilities tracked',
],
domainLabel: `sim.ai${model.href}`,
})
}

View File

@@ -0,0 +1,390 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
import {
Breadcrumbs,
CapabilityTags,
DetailItem,
ModelCard,
ProviderIcon,
StatCard,
} from '@/app/(landing)/models/components/model-primitives'
import {
ALL_CATALOG_MODELS,
buildModelCapabilityFacts,
buildModelFaqs,
formatPrice,
formatTokenCount,
formatUpdatedAt,
getModelBySlug,
getPricingBounds,
getProviderBySlug,
getRelatedModels,
} from '@/app/(landing)/models/utils'
const baseUrl = getBaseUrl()
export async function generateStaticParams() {
return ALL_CATALOG_MODELS.map((model) => ({
provider: model.providerSlug,
model: model.slug,
}))
}
export async function generateMetadata({
params,
}: {
params: Promise<{ provider: string; model: string }>
}): Promise<Metadata> {
const { provider: providerSlug, model: modelSlug } = await params
const provider = getProviderBySlug(providerSlug)
const model = getModelBySlug(providerSlug, modelSlug)
if (!provider || !model) {
return {}
}
return {
title: `${model.displayName} Pricing, Context Window, and Features`,
description: `${model.displayName} by ${provider.name}: pricing, cached input cost, output cost, context window, and capability support. Explore the full generated model page on Sim.`,
keywords: [
model.displayName,
`${model.displayName} pricing`,
`${model.displayName} context window`,
`${model.displayName} features`,
`${provider.name} ${model.displayName}`,
`${provider.name} model pricing`,
...model.capabilityTags,
],
openGraph: {
title: `${model.displayName} Pricing, Context Window, and Features | Sim`,
description: `${model.displayName} by ${provider.name}: pricing, context window, and model capability details.`,
url: `${baseUrl}${model.href}`,
type: 'website',
images: [
{
url: `${baseUrl}${model.href}/opengraph-image`,
width: 1200,
height: 630,
alt: `${model.displayName} on Sim`,
},
],
},
twitter: {
card: 'summary_large_image',
title: `${model.displayName} | Sim`,
description: model.summary,
images: [
{ url: `${baseUrl}${model.href}/opengraph-image`, alt: `${model.displayName} on Sim` },
],
},
alternates: {
canonical: `${baseUrl}${model.href}`,
},
}
}
export default async function ModelPage({
params,
}: {
params: Promise<{ provider: string; model: string }>
}) {
const { provider: providerSlug, model: modelSlug } = await params
const provider = getProviderBySlug(providerSlug)
const model = getModelBySlug(providerSlug, modelSlug)
if (!provider || !model) {
notFound()
}
const faqs = buildModelFaqs(provider, model)
const capabilityFacts = buildModelCapabilityFacts(model)
const pricingBounds = getPricingBounds(model.pricing)
const relatedModels = getRelatedModels(model, 6)
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl },
{ '@type': 'ListItem', position: 2, name: 'Models', item: `${baseUrl}/models` },
{ '@type': 'ListItem', position: 3, name: provider.name, item: `${baseUrl}${provider.href}` },
{
'@type': 'ListItem',
position: 4,
name: model.displayName,
item: `${baseUrl}${model.href}`,
},
],
}
const productJsonLd = {
'@context': 'https://schema.org',
'@type': 'Product',
name: model.displayName,
brand: provider.name,
category: 'AI language model',
description: model.summary,
sku: model.id,
offers: {
'@type': 'AggregateOffer',
priceCurrency: 'USD',
lowPrice: pricingBounds.lowPrice.toString(),
highPrice: pricingBounds.highPrice.toString(),
},
}
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqs.map((faq) => ({
'@type': 'Question',
name: faq.question,
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer,
},
})),
}
return (
<>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
/>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(productJsonLd) }}
/>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
/>
<div className='mx-auto max-w-[1280px] px-6 py-12 sm:px-8 md:px-12'>
<Breadcrumbs
items={[
{ label: 'Home', href: '/' },
{ label: 'Models', href: '/models' },
{ label: provider.name, href: provider.href },
{ label: model.displayName },
]}
/>
<section aria-labelledby='model-heading' className='mb-14'>
<div className='mb-6 flex items-start gap-4'>
<ProviderIcon
provider={provider}
className='h-16 w-16 rounded-3xl'
iconClassName='h-8 w-8'
/>
<div className='min-w-0'>
<p className='text-[12px] text-[var(--landing-text-muted)] uppercase tracking-[0.12em]'>
{provider.name} model
</p>
<h1
id='model-heading'
className='font-[500] text-[38px] text-[var(--landing-text)] leading-tight sm:text-[48px]'
>
{model.displayName}
</h1>
<p className='mt-2 break-all text-[13px] text-[var(--landing-text-muted)]'>
Model ID: {model.id}
</p>
</div>
</div>
<p className='max-w-[820px] text-[17px] text-[var(--landing-text-muted)] leading-relaxed'>
{model.summary} {model.bestFor}
</p>
<div className='mt-8 flex flex-wrap gap-3'>
<Link
href={provider.href}
className='inline-flex h-[34px] items-center rounded-[6px] border border-[var(--landing-border-strong)] px-3 font-[430] text-[14px] text-[var(--landing-text)] transition-colors hover:bg-[var(--landing-bg-elevated)]'
>
Explore {provider.name} models
</Link>
<a
href='https://sim.ai'
className='inline-flex h-[34px] items-center rounded-[6px] border border-[var(--white)] bg-[var(--white)] px-3 font-[430] text-[14px] text-[var(--landing-text-dark)] transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
>
Build with this model
</a>
</div>
</section>
<section
aria-label='Model stats'
className='mb-16 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4'
>
<StatCard label='Input price' value={`${formatPrice(model.pricing.input)}/1M`} />
<StatCard
label='Cached input'
value={
model.pricing.cachedInput !== undefined
? `${formatPrice(model.pricing.cachedInput)}/1M`
: 'N/A'
}
compact
/>
<StatCard label='Output price' value={`${formatPrice(model.pricing.output)}/1M`} />
<StatCard
label='Context window'
value={model.contextWindow ? formatTokenCount(model.contextWindow) : 'Unknown'}
compact
/>
</section>
<div className='grid grid-cols-1 gap-16 lg:grid-cols-[1fr_320px]'>
<div className='min-w-0 space-y-16'>
<section aria-labelledby='pricing-heading'>
<h2
id='pricing-heading'
className='mb-2 font-[500] text-[28px] text-[var(--landing-text)]'
>
Pricing and limits
</h2>
<p className='mb-6 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
Pricing below is generated directly from the provider registry in Sim. All amounts
are listed per one million tokens.
</p>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4'>
<DetailItem label='Input price' value={`${formatPrice(model.pricing.input)}/1M`} />
<DetailItem
label='Cached input'
value={
model.pricing.cachedInput !== undefined
? `${formatPrice(model.pricing.cachedInput)}/1M`
: 'N/A'
}
/>
<DetailItem
label='Output price'
value={`${formatPrice(model.pricing.output)}/1M`}
/>
<DetailItem label='Updated' value={formatUpdatedAt(model.pricing.updatedAt)} />
<DetailItem
label='Context window'
value={
model.contextWindow
? `${formatTokenCount(model.contextWindow)} tokens`
: 'Unknown'
}
/>
<DetailItem
label='Max output'
value={
model.capabilities.maxOutputTokens
? `${formatTokenCount(model.capabilities.maxOutputTokens)} tokens`
: 'Standard defaults'
}
/>
<DetailItem label='Provider' value={provider.name} />
<DetailItem label='Best for' value={model.bestFor} />
</div>
</section>
<section aria-labelledby='capabilities-heading'>
<h2
id='capabilities-heading'
className='mb-2 font-[500] text-[28px] text-[var(--landing-text)]'
>
Capabilities
</h2>
<p className='mb-6 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
These capability flags are generated from the provider and model definitions tracked
in Sim.
</p>
<CapabilityTags tags={model.capabilityTags} />
<div className='mt-8 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3'>
{capabilityFacts.map((item) => (
<DetailItem key={item.label} label={item.label} value={item.value} />
))}
</div>
</section>
{relatedModels.length > 0 && (
<section aria-labelledby='related-models-heading'>
<h2
id='related-models-heading'
className='mb-2 font-[500] text-[28px] text-[var(--landing-text)]'
>
Related {provider.name} models
</h2>
<p className='mb-8 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
Browse comparable models from the same provider to compare pricing, context
window, and capability coverage.
</p>
<div className='grid grid-cols-1 gap-4 xl:grid-cols-2'>
{relatedModels.map((entry) => (
<ModelCard key={entry.id} provider={provider} model={entry} />
))}
</div>
</section>
)}
<section
aria-labelledby='model-faq-heading'
className='rounded-3xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-6 sm:p-8'
>
<h2
id='model-faq-heading'
className='font-[500] text-[28px] text-[var(--landing-text)]'
>
Frequently asked questions
</h2>
<div className='mt-3'>
<LandingFAQ faqs={faqs} />
</div>
</section>
</div>
<aside className='space-y-5' aria-label='Model details'>
<div className='rounded-3xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-5'>
<h2 className='mb-4 font-[500] text-[16px] text-[var(--landing-text)]'>
Quick details
</h2>
<div className='space-y-3'>
<DetailItem label='Display name' value={model.displayName} />
<DetailItem label='Provider' value={provider.name} />
<DetailItem
label='Context tracked'
value={model.contextWindow ? 'Yes' : 'Partial'}
/>
<DetailItem
label='Pricing updated'
value={formatUpdatedAt(model.pricing.updatedAt)}
/>
</div>
</div>
<div className='rounded-3xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-5'>
<h2 className='mb-4 font-[500] text-[16px] text-[var(--landing-text)]'>
Browse more
</h2>
<div className='space-y-2'>
<Link
href={provider.href}
className='block rounded-xl px-3 py-2 text-[14px] text-[var(--landing-text-muted)] transition-colors hover:bg-[var(--landing-bg-elevated)] hover:text-[var(--landing-text)]'
>
All {provider.name} models
</Link>
<Link
href='/models'
className='block rounded-xl px-3 py-2 text-[14px] text-[var(--landing-text-muted)] transition-colors hover:bg-[var(--landing-bg-elevated)] hover:text-[var(--landing-text)]'
>
Full models directory
</Link>
</div>
</div>
</aside>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,43 @@
import { notFound } from 'next/navigation'
import { createModelsOgImage } from '@/app/(landing)/models/og-utils'
import {
formatPrice,
formatTokenCount,
getCheapestProviderModel,
getLargestContextProviderModel,
getProviderBySlug,
} from '@/app/(landing)/models/utils'
export const runtime = 'edge'
export const contentType = 'image/png'
export const size = {
width: 1200,
height: 630,
}
export default async function Image({ params }: { params: Promise<{ provider: string }> }) {
const { provider: providerSlug } = await params
const provider = getProviderBySlug(providerSlug)
if (!provider || provider.models.length === 0) {
notFound()
}
const cheapestModel = getCheapestProviderModel(provider)
const largestContextModel = getLargestContextProviderModel(provider)
return createModelsOgImage({
eyebrow: `${provider.name} on Sim`,
title: `${provider.name} models`,
subtitle: `Browse ${provider.modelCount} tracked ${provider.name} models with pricing, context windows, default model selection, and model capability coverage.`,
pills: [
`${provider.modelCount} tracked`,
provider.defaultModelDisplayName || 'Dynamic default',
cheapestModel ? `From ${formatPrice(cheapestModel.pricing.input)}/1M` : 'Pricing tracked',
largestContextModel?.contextWindow
? `${formatTokenCount(largestContextModel.contextWindow)} context`
: 'Context tracked',
],
domainLabel: `sim.ai${provider.href}`,
})
}

View File

@@ -0,0 +1,294 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
import {
Breadcrumbs,
CapabilityTags,
ModelCard,
ProviderCard,
ProviderIcon,
StatCard,
} from '@/app/(landing)/models/components/model-primitives'
import {
buildProviderFaqs,
getProviderBySlug,
getProviderCapabilitySummary,
MODEL_PROVIDERS_WITH_CATALOGS,
TOP_MODEL_PROVIDERS,
} from '@/app/(landing)/models/utils'
const baseUrl = getBaseUrl()
export async function generateStaticParams() {
return MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => ({
provider: provider.slug,
}))
}
export async function generateMetadata({
params,
}: {
params: Promise<{ provider: string }>
}): Promise<Metadata> {
const { provider: providerSlug } = await params
const provider = getProviderBySlug(providerSlug)
if (!provider || provider.models.length === 0) {
return {}
}
const providerFaqs = buildProviderFaqs(provider)
return {
title: `${provider.name} Models`,
description: `Browse ${provider.modelCount} ${provider.name} models tracked in Sim. Compare pricing, context windows, default model selection, and capabilities for ${provider.name}'s AI model lineup.`,
keywords: [
`${provider.name} models`,
`${provider.name} pricing`,
`${provider.name} context window`,
`${provider.name} model list`,
`${provider.name} AI models`,
...provider.models.slice(0, 6).map((model) => model.displayName),
],
openGraph: {
title: `${provider.name} Models | Sim`,
description: `Explore ${provider.modelCount} ${provider.name} models with pricing and capability details.`,
url: `${baseUrl}${provider.href}`,
type: 'website',
images: [
{
url: `${baseUrl}${provider.href}/opengraph-image`,
width: 1200,
height: 630,
alt: `${provider.name} Models on Sim`,
},
],
},
twitter: {
card: 'summary_large_image',
title: `${provider.name} Models | Sim`,
description: providerFaqs[0]?.answer ?? provider.summary,
images: [
{
url: `${baseUrl}${provider.href}/opengraph-image`,
alt: `${provider.name} Models on Sim`,
},
],
},
alternates: {
canonical: `${baseUrl}${provider.href}`,
},
}
}
export default async function ProviderModelsPage({
params,
}: {
params: Promise<{ provider: string }>
}) {
const { provider: providerSlug } = await params
const provider = getProviderBySlug(providerSlug)
if (!provider || provider.models.length === 0) {
notFound()
}
const faqs = buildProviderFaqs(provider)
const capabilitySummary = getProviderCapabilitySummary(provider)
const relatedProviders = MODEL_PROVIDERS_WITH_CATALOGS.filter(
(entry) => entry.id !== provider.id && TOP_MODEL_PROVIDERS.includes(entry.name)
).slice(0, 4)
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl },
{ '@type': 'ListItem', position: 2, name: 'Models', item: `${baseUrl}/models` },
{ '@type': 'ListItem', position: 3, name: provider.name, item: `${baseUrl}${provider.href}` },
],
}
const itemListJsonLd = {
'@context': 'https://schema.org',
'@type': 'ItemList',
name: `${provider.name} Models`,
description: `List of ${provider.modelCount} ${provider.name} models tracked in Sim.`,
url: `${baseUrl}${provider.href}`,
numberOfItems: provider.modelCount,
itemListElement: provider.models.map((model, index) => ({
'@type': 'ListItem',
position: index + 1,
url: `${baseUrl}${model.href}`,
name: model.displayName,
})),
}
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqs.map((faq) => ({
'@type': 'Question',
name: faq.question,
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer,
},
})),
}
return (
<>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
/>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(itemListJsonLd) }}
/>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
/>
<div className='mx-auto max-w-[1280px] px-6 py-12 sm:px-8 md:px-12'>
<Breadcrumbs
items={[
{ label: 'Home', href: '/' },
{ label: 'Models', href: '/models' },
{ label: provider.name },
]}
/>
<section aria-labelledby='provider-heading' className='mb-14'>
<div className='mb-6 flex items-center gap-4'>
<ProviderIcon
provider={provider}
className='h-16 w-16 rounded-3xl'
iconClassName='h-8 w-8'
/>
<div>
<p className='text-[12px] text-[var(--landing-text-muted)] uppercase tracking-[0.12em]'>
Provider
</p>
<h1
id='provider-heading'
className='font-[500] text-[38px] text-[var(--landing-text)] leading-tight sm:text-[48px]'
>
{provider.name} models
</h1>
</div>
</div>
<p className='max-w-[820px] text-[17px] text-[var(--landing-text-muted)] leading-relaxed'>
{provider.summary} Browse every {provider.name} model page generated from Sim&apos;s
provider registry with human-readable names, pricing, context windows, and capability
metadata.
</p>
<div className='mt-8 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4'>
<StatCard label='Models tracked' value={provider.modelCount.toString()} />
<StatCard
label='Default model'
value={provider.defaultModelDisplayName || 'Dynamic'}
compact
/>
<StatCard
label='Metadata coverage'
value={provider.contextInformationAvailable ? 'Tracked' : 'Partial'}
compact
/>
<StatCard
label='Featured models'
value={provider.featuredModels.length.toString()}
compact
/>
</div>
<div className='mt-6'>
<CapabilityTags tags={provider.providerCapabilityTags} />
</div>
</section>
<section aria-labelledby='provider-models-heading' className='mb-16'>
<h2
id='provider-models-heading'
className='mb-2 font-[500] text-[28px] text-[var(--landing-text)]'
>
All {provider.name} models
</h2>
<p className='mb-8 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
Every model below links to a dedicated SEO page with exact pricing, context window,
capability support, and related model recommendations.
</p>
<div className='grid grid-cols-1 gap-4 xl:grid-cols-2'>
{provider.models.map((model) => (
<ModelCard key={model.id} provider={provider} model={model} />
))}
</div>
</section>
<section
aria-labelledby='lineup-snapshot-heading'
className='mb-16 rounded-3xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-6 sm:p-8'
>
<h2
id='lineup-snapshot-heading'
className='mb-2 font-[500] text-[28px] text-[var(--landing-text)]'
>
Lineup snapshot
</h2>
<p className='mb-8 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
A quick view of the strongest differentiators in the {provider.name} model lineup based
on the metadata currently tracked in Sim.
</p>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3'>
{capabilitySummary.map((item) => (
<StatCard key={item.label} label={item.label} value={item.value} compact />
))}
</div>
</section>
{relatedProviders.length > 0 && (
<section aria-labelledby='related-providers-heading' className='mb-16'>
<h2
id='related-providers-heading'
className='mb-2 font-[500] text-[28px] text-[var(--landing-text)]'
>
Compare with other providers
</h2>
<p className='mb-8 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
Explore similar provider hubs to compare model lineups, pricing surfaces, and
long-context coverage across the broader AI ecosystem.
</p>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4'>
{relatedProviders.map((entry) => (
<ProviderCard key={entry.id} provider={entry} />
))}
</div>
</section>
)}
<section
aria-labelledby='provider-faq-heading'
className='rounded-3xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-6 sm:p-8'
>
<h2
id='provider-faq-heading'
className='font-[500] text-[28px] text-[var(--landing-text)]'
>
Frequently asked questions
</h2>
<div className='mt-3'>
<LandingFAQ faqs={faqs} />
</div>
</section>
</div>
</>
)
}

View File

@@ -0,0 +1,291 @@
'use client'
import { useMemo, useState } from 'react'
import Link from 'next/link'
import { Input } from '@/components/emcn'
import { SearchIcon } from '@/components/icons'
import { cn } from '@/lib/core/utils/cn'
import {
CapabilityTags,
DetailItem,
ModelCard,
ProviderIcon,
StatCard,
} from '@/app/(landing)/models/components/model-primitives'
import {
type CatalogProvider,
MODEL_PROVIDERS_WITH_CATALOGS,
MODEL_PROVIDERS_WITH_DYNAMIC_CATALOGS,
TOTAL_MODELS,
} from '@/app/(landing)/models/utils'
export function ModelDirectory() {
const [query, setQuery] = useState('')
const [activeProviderId, setActiveProviderId] = useState<string | null>(null)
const providerOptions = useMemo(
() =>
MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => ({
id: provider.id,
name: provider.name,
count: provider.modelCount,
})),
[]
)
const normalizedQuery = query.trim().toLowerCase()
const { filteredProviders, filteredDynamicProviders, visibleModelCount } = useMemo(() => {
const filteredProviders = MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => {
const providerMatchesSearch =
normalizedQuery.length > 0 && provider.searchText.includes(normalizedQuery)
const providerMatchesFilter = !activeProviderId || provider.id === activeProviderId
if (!providerMatchesFilter) {
return null
}
const models =
normalizedQuery.length === 0
? provider.models
: provider.models.filter(
(model) =>
model.searchText.includes(normalizedQuery) ||
(providerMatchesSearch && normalizedQuery.length > 0)
)
if (!providerMatchesSearch && models.length === 0) {
return null
}
return {
...provider,
models: providerMatchesSearch && normalizedQuery.length > 0 ? provider.models : models,
}
}).filter((provider): provider is CatalogProvider => provider !== null)
const filteredDynamicProviders = MODEL_PROVIDERS_WITH_DYNAMIC_CATALOGS.filter((provider) => {
const providerMatchesFilter = !activeProviderId || provider.id === activeProviderId
if (!providerMatchesFilter) {
return false
}
if (!normalizedQuery) {
return true
}
return provider.searchText.includes(normalizedQuery)
})
const visibleModelCount = filteredProviders.reduce(
(count, provider) => count + provider.models.length,
0
)
return {
filteredProviders,
filteredDynamicProviders,
visibleModelCount,
}
}, [activeProviderId, normalizedQuery])
const hasResults = filteredProviders.length > 0 || filteredDynamicProviders.length > 0
return (
<div>
<div className='mb-8 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between'>
<div className='relative max-w-[560px] flex-1'>
<SearchIcon
aria-hidden='true'
className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-4 w-4 text-[var(--landing-text-muted)]'
/>
<Input
type='search'
placeholder='Search models, providers, capabilities, or pricing details'
value={query}
onChange={(event) => setQuery(event.target.value)}
className='h-11 border-[var(--landing-border)] bg-[var(--landing-bg-card)] pl-10 text-[var(--landing-text)] placeholder:text-[var(--landing-text-muted)]'
aria-label='Search AI models'
/>
</div>
<p className='text-[13px] text-[var(--landing-text-muted)] leading-relaxed'>
Showing {visibleModelCount.toLocaleString('en-US')} of{' '}
{TOTAL_MODELS.toLocaleString('en-US')} models
{activeProviderId ? ' in one provider' : ''}.
</p>
</div>
<div className='mb-10 flex flex-wrap gap-2'>
<FilterButton
isActive={activeProviderId === null}
onClick={() => setActiveProviderId(null)}
label={`All providers (${MODEL_PROVIDERS_WITH_CATALOGS.length})`}
/>
{providerOptions.map((provider) => (
<FilterButton
key={provider.id}
isActive={activeProviderId === provider.id}
onClick={() =>
setActiveProviderId(activeProviderId === provider.id ? null : provider.id)
}
label={`${provider.name} (${provider.count})`}
/>
))}
</div>
{!hasResults ? (
<div className='rounded-2xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] px-6 py-12 text-center'>
<h3 className='font-[500] text-[18px] text-[var(--landing-text)]'>No matches found</h3>
<p className='mt-2 text-[14px] text-[var(--landing-text-muted)] leading-relaxed'>
Try a provider name like OpenAI or Anthropic, or search for capabilities like
&nbsp;structured outputs, reasoning, or deep research.
</p>
</div>
) : (
<div className='space-y-10'>
{filteredProviders.map((provider) => (
<section
key={provider.id}
aria-labelledby={`${provider.id}-heading`}
className='rounded-3xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-6 sm:p-8'
>
<div className='mb-6 flex flex-col gap-5 border-[var(--landing-border)] border-b pb-6 lg:flex-row lg:items-start lg:justify-between'>
<div className='min-w-0'>
<div className='mb-3 flex items-center gap-3'>
<ProviderIcon provider={provider} />
<div>
<p className='text-[12px] text-[var(--landing-text-muted)]'>Provider</p>
<h2
id={`${provider.id}-heading`}
className='font-[500] text-[24px] text-[var(--landing-text)]'
>
{provider.name}
</h2>
</div>
</div>
<p className='max-w-[720px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
{provider.description}
</p>
<Link
href={provider.href}
className='mt-3 inline-flex text-[#555] text-[13px] transition-colors hover:text-[var(--landing-text-muted)]'
>
View provider page
</Link>
</div>
<div className='grid shrink-0 grid-cols-2 gap-3 sm:grid-cols-3'>
<StatCard label='Models' value={provider.models.length.toString()} />
<StatCard
label='Default'
value={provider.defaultModelDisplayName || 'Dynamic'}
compact
/>
<StatCard
label='Context info'
value={provider.contextInformationAvailable ? 'Tracked' : 'Limited'}
compact
/>
</div>
</div>
<div className='mb-6'>
<CapabilityTags tags={provider.providerCapabilityTags} />
</div>
<div className='grid grid-cols-1 gap-4 xl:grid-cols-2'>
{provider.models.map((model) => (
<ModelCard key={model.id} provider={provider} model={model} />
))}
</div>
</section>
))}
{filteredDynamicProviders.length > 0 && (
<section
aria-labelledby='dynamic-catalogs-heading'
className='rounded-3xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-6 sm:p-8'
>
<div className='mb-6'>
<h2
id='dynamic-catalogs-heading'
className='font-[500] text-[24px] text-[var(--landing-text)]'
>
Dynamic model catalogs
</h2>
<p className='mt-2 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
These providers are supported by Sim, but their model lists are loaded dynamically
at runtime rather than hard-coded into the public catalog.
</p>
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4'>
{filteredDynamicProviders.map((provider) => (
<article
key={provider.id}
className='rounded-2xl border border-[var(--landing-border)] bg-[var(--landing-bg-elevated)] p-5'
>
<div className='mb-4 flex items-center gap-3'>
<ProviderIcon provider={provider} />
<div className='min-w-0'>
<h3 className='font-[500] text-[16px] text-[var(--landing-text)]'>
{provider.name}
</h3>
<p className='text-[12px] text-[var(--landing-text-muted)]'>
{provider.id}
</p>
</div>
</div>
<p className='text-[13px] text-[var(--landing-text-muted)] leading-relaxed'>
{provider.description}
</p>
<div className='mt-4 space-y-3 text-[13px]'>
<DetailItem
label='Default'
value={provider.defaultModelDisplayName || 'Selected at runtime'}
/>
<DetailItem label='Catalog source' value='Loaded dynamically inside Sim' />
</div>
<div className='mt-4'>
<CapabilityTags tags={provider.providerCapabilityTags} />
</div>
</article>
))}
</div>
</section>
)}
</div>
)}
</div>
)
}
function FilterButton({
isActive,
onClick,
label,
}: {
isActive: boolean
onClick: () => void
label: string
}) {
return (
<button
type='button'
onClick={onClick}
className={cn(
'rounded-full border px-3 py-1.5 text-[12px] transition-colors',
isActive
? 'border-[#555] bg-[#333] text-[var(--landing-text)]'
: 'border-[var(--landing-border)] bg-transparent text-[var(--landing-text-muted)] hover:border-[var(--landing-border-strong)] hover:text-[var(--landing-text)]'
)}
>
{label}
</button>
)
}

View File

@@ -0,0 +1,214 @@
import Link from 'next/link'
import { Badge } from '@/components/emcn'
import {
type CatalogModel,
type CatalogProvider,
formatPrice,
formatTokenCount,
formatUpdatedAt,
} from '@/app/(landing)/models/utils'
export function Breadcrumbs({ items }: { items: Array<{ label: string; href?: string }> }) {
return (
<nav
aria-label='Breadcrumb'
className='mb-10 flex flex-wrap items-center gap-2 text-[#555] text-[13px]'
>
{items.map((item, index) => (
<span key={`${item.label}-${index}`} className='inline-flex items-center gap-2'>
{item.href ? (
<Link
href={item.href}
className='transition-colors hover:text-[var(--landing-text-muted)]'
>
{item.label}
</Link>
) : (
<span className='text-[var(--landing-text-muted)]'>{item.label}</span>
)}
{index < items.length - 1 ? <span aria-hidden='true'>/</span> : null}
</span>
))}
</nav>
)
}
export function ProviderIcon({
provider,
className = 'h-12 w-12 rounded-2xl',
iconClassName = 'h-6 w-6',
}: {
provider: Pick<CatalogProvider, 'icon' | 'name'>
className?: string
iconClassName?: string
}) {
const Icon = provider.icon
return (
<span
className={`flex items-center justify-center border border-[var(--landing-border)] bg-[var(--landing-bg)] ${className}`}
>
{Icon ? (
<Icon className={iconClassName} />
) : (
<span className='font-[500] text-[14px] text-[var(--landing-text)]'>
{provider.name.slice(0, 2).toUpperCase()}
</span>
)}
</span>
)
}
export function StatCard({
label,
value,
compact = false,
}: {
label: string
value: string
compact?: boolean
}) {
return (
<div className='rounded-2xl border border-[var(--landing-border)] bg-[var(--landing-bg-elevated)] px-4 py-3'>
<p className='text-[11px] text-[var(--landing-text-muted)] uppercase tracking-[0.08em]'>
{label}
</p>
<p
className={`mt-1 font-[500] text-[var(--landing-text)] ${
compact ? 'break-all text-[12px] leading-snug' : 'text-[18px]'
}`}
>
{value}
</p>
</div>
)
}
export function DetailItem({ label, value }: { label: string; value: string }) {
return (
<div className='rounded-xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] px-3 py-2'>
<p className='text-[11px] text-[var(--landing-text-muted)] uppercase tracking-[0.08em]'>
{label}
</p>
<p className='mt-1 break-words font-[500] text-[12px] text-[var(--landing-text)] leading-snug'>
{value}
</p>
</div>
)
}
export function CapabilityTags({ tags }: { tags: string[] }) {
if (tags.length === 0) {
return null
}
return (
<div className='flex flex-wrap gap-2'>
{tags.map((tag) => (
<Badge
key={tag}
className='border-[var(--landing-border)] bg-transparent px-2 py-1 text-[11px] text-[var(--landing-text-muted)]'
>
{tag}
</Badge>
))}
</div>
)
}
export function ProviderCard({ provider }: { provider: CatalogProvider }) {
return (
<Link
href={provider.href}
className='group flex h-full flex-col rounded-lg border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-4 transition-colors hover:border-[var(--landing-border-strong)] hover:bg-[var(--landing-bg-elevated)]'
>
<div className='mb-4 flex items-center gap-3'>
<ProviderIcon provider={provider} />
<div className='min-w-0'>
<h3 className='font-[500] text-[18px] text-[var(--landing-text)]'>{provider.name}</h3>
<p className='text-[12px] text-[var(--landing-text-muted)]'>
{provider.modelCount} models tracked
</p>
</div>
</div>
<p className='mb-4 flex-1 text-[14px] text-[var(--landing-text-muted)] leading-relaxed'>
{provider.description}
</p>
<div className='mb-4 grid grid-cols-2 gap-3'>
<DetailItem label='Default' value={provider.defaultModelDisplayName || 'Dynamic'} />
<DetailItem
label='Catalog'
value={provider.contextInformationAvailable ? 'Tracked metadata' : 'Partial metadata'}
/>
</div>
<CapabilityTags tags={provider.providerCapabilityTags.slice(0, 4)} />
<p className='mt-4 text-[#555] text-[13px] transition-colors group-hover:text-[var(--landing-text-muted)]'>
Explore provider
</p>
</Link>
)
}
export function ModelCard({
provider,
model,
showProvider = false,
}: {
provider: CatalogProvider
model: CatalogModel
showProvider?: boolean
}) {
return (
<Link
href={model.href}
className='group flex h-full flex-col rounded-lg border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-4 transition-colors hover:border-[var(--landing-border-strong)] hover:bg-[var(--landing-bg-elevated)]'
>
<div className='mb-4 flex items-start gap-3'>
<ProviderIcon
provider={provider}
className='h-10 w-10 rounded-xl'
iconClassName='h-5 w-5'
/>
<div className='min-w-0 flex-1'>
{showProvider ? (
<p className='mb-1 text-[12px] text-[var(--landing-text-muted)]'>{provider.name}</p>
) : null}
<h3 className='break-all font-[500] text-[16px] text-[var(--landing-text)] leading-snug'>
{model.displayName}
</h3>
<p className='mt-1 break-all text-[12px] text-[var(--landing-text-muted)]'>{model.id}</p>
</div>
</div>
<p className='mb-3 line-clamp-3 flex-1 text-[12px] text-[var(--landing-text-muted)] leading-relaxed'>
{model.summary}
</p>
<div className='flex flex-wrap items-center gap-1.5'>
<Badge className='border-0 bg-[#333] text-[11px] text-[var(--landing-text-muted)]'>
{`Input ${formatPrice(model.pricing.input)}/1M`}
</Badge>
<Badge className='border-0 bg-[#333] text-[11px] text-[var(--landing-text-muted)]'>
{`Output ${formatPrice(model.pricing.output)}/1M`}
</Badge>
<Badge className='border-0 bg-[#333] text-[11px] text-[var(--landing-text-muted)]'>
{model.contextWindow
? `${formatTokenCount(model.contextWindow)} context`
: 'Unknown context'}
</Badge>
{model.capabilityTags[0] ? (
<Badge className='border-0 bg-[#333] text-[11px] text-[var(--landing-text-muted)]'>
{model.capabilityTags[0]}
</Badge>
) : null}
<span className='ml-auto text-[#555] text-[12px] transition-colors group-hover:text-[var(--landing-text-muted)]'>
{`Updated ${formatUpdatedAt(model.pricing.updatedAt)}`}
</span>
</div>
</Link>
)
}

View File

@@ -0,0 +1,42 @@
import { getNavBlogPosts } from '@/lib/blog/registry'
import { getBaseUrl } from '@/lib/core/utils/urls'
import Footer from '@/app/(home)/components/footer/footer'
import Navbar from '@/app/(home)/components/navbar/navbar'
export default async function ModelsLayout({ children }: { children: React.ReactNode }) {
const blogPosts = await getNavBlogPosts()
const url = getBaseUrl()
const orgJsonLd = {
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Sim',
url,
logo: `${url}/logo/primary/small.png`,
sameAs: ['https://x.com/simdotai'],
}
const websiteJsonLd = {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'Sim',
url,
}
return (
<div className='dark flex min-h-screen flex-col bg-[var(--landing-bg)] font-[430] font-season text-[var(--landing-text)]'>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(orgJsonLd) }}
/>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteJsonLd) }}
/>
<header>
<Navbar blogPosts={blogPosts} />
</header>
<main className='relative flex-1'>{children}</main>
<Footer />
</div>
)
}

View File

@@ -0,0 +1,205 @@
import { ImageResponse } from 'next/og'
const size = {
width: 1200,
height: 630,
}
const TITLE_FONT_SIZE = {
large: 64,
medium: 56,
small: 48,
} as const
function getTitleFontSize(title: string): number {
if (title.length > 42) return TITLE_FONT_SIZE.small
if (title.length > 26) return TITLE_FONT_SIZE.medium
return TITLE_FONT_SIZE.large
}
async function loadGoogleFont(
font: string,
weights: string,
text: string
): Promise<ArrayBuffer | null> {
try {
const url = `https://fonts.googleapis.com/css2?family=${font}:wght@${weights}&text=${encodeURIComponent(text)}`
const css = await (await fetch(url)).text()
const resource = css.match(/src: url\(([^)]+)\) format\('(opentype|truetype|woff2?)'\)/)
if (resource) {
const response = await fetch(resource[1])
if (response.status === 200) {
return await response.arrayBuffer()
}
}
} catch {
return null
}
return null
}
function SimLogoFull() {
return (
<svg height='28' viewBox='720 440 1020 320' fill='none'>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M875.791 577.171C875.791 581.922 873.911 586.483 870.576 589.842L870.098 590.323C866.764 593.692 862.234 595.575 857.517 595.575H750.806C740.978 595.575 733 603.6 733 613.498V728.902C733 738.799 740.978 746.826 750.806 746.826H865.382C875.209 746.826 883.177 738.799 883.177 728.902V620.853C883.177 616.448 884.912 612.222 888.008 609.104C891.093 605.997 895.29 604.249 899.664 604.249H1008.16C1017.99 604.249 1025.96 596.224 1025.96 586.327V470.923C1025.96 461.025 1017.99 453 1008.16 453H893.586C883.759 453 875.791 461.025 875.791 470.923V577.171ZM910.562 477.566H991.178C996.922 477.566 1001.57 482.254 1001.57 488.029V569.22C1001.57 574.995 996.922 579.683 991.178 579.683H910.562C904.828 579.683 900.173 574.995 900.173 569.22V488.029C900.173 482.254 904.828 477.566 910.562 477.566Z'
fill='#33C482'
/>
<path
d='M1008.3 624.59H923.113C912.786 624.59 904.414 633.022 904.414 643.423V728.171C904.414 738.572 912.786 747.004 923.113 747.004H1008.3C1018.63 747.004 1027 738.572 1027 728.171V643.423C1027 633.022 1018.63 624.59 1008.3 624.59Z'
fill='#33C482'
/>
<path
d='M1210.54 515.657C1226.65 515.657 1240.59 518.51 1252.31 524.257H1252.31C1264.3 529.995 1273.63 538.014 1280.26 548.319H1280.26C1287.19 558.635 1290.78 570.899 1291.08 585.068L1291.1 586.089H1249.11L1249.09 585.115C1248.8 574.003 1245.18 565.493 1238.32 559.451C1231.45 553.399 1221.79 550.308 1209.21 550.308C1196.3 550.308 1186.48 553.113 1179.61 558.588C1172.76 564.046 1169.33 571.499 1169.33 581.063C1169.33 588.092 1171.88 593.978 1177.01 598.783C1182.17 603.618 1189.99 607.399 1200.56 610.061H1200.56L1238.77 619.451C1257.24 623.65 1271.21 630.571 1280.57 640.293L1281.01 640.739C1290.13 650.171 1294.64 662.97 1294.64 679.016C1294.64 692.923 1290.88 705.205 1283.34 715.822L1283.33 715.834C1275.81 726.134 1265.44 734.14 1252.26 739.866L1252.25 739.871C1239.36 745.302 1224.12 748 1206.54 748C1180.9 748 1160.36 741.696 1145.02 728.984C1129.67 716.258 1122 699.269 1122 678.121V677.121H1163.99V678.121C1163.99 688.869 1167.87 697.367 1175.61 703.722L1176.34 704.284C1184.04 709.997 1194.37 712.902 1207.43 712.902C1222.13 712.902 1233.3 710.087 1241.07 704.588C1248.8 698.812 1252.64 691.21 1252.64 681.699C1252.64 674.769 1250.5 669.057 1246.25 664.49L1246.23 664.478L1246.22 664.464C1242.28 659.929 1234.83 656.119 1223.64 653.152L1185.43 644.208L1185.42 644.204C1166.05 639.407 1151.49 632.035 1141.83 622.012L1141.83 622.006L1141.82 622C1132.43 611.94 1127.78 598.707 1127.78 582.405C1127.78 568.81 1131.23 556.976 1138.17 546.949L1138.18 546.941L1138.19 546.933C1145.41 536.936 1155.18 529.225 1167.48 523.793L1167.48 523.79C1180.07 518.36 1194.43 515.657 1210.54 515.657ZM1323.39 521.979C1331.68 525.008 1337.55 526.482 1343.51 526.482C1349.48 526.482 1355.64 525.005 1364.49 521.973L1365.82 521.52V742.633H1322.05V521.489L1323.39 521.979ZM1642.01 515.657C1667.11 515.657 1686.94 523.031 1701.39 537.876C1715.83 552.716 1723 572.968 1723 598.507V742.633H1680.12V608.794C1680.12 591.666 1675.72 578.681 1667.07 569.681L1667.06 569.669L1667.04 569.656C1658.67 560.359 1647.26 555.675 1632.68 555.675C1622.47 555.675 1613.47 558.022 1605.64 562.69L1605.63 562.696C1598.11 567.064 1592.17 573.475 1587.8 581.968C1583.44 590.448 1581.25 600.424 1581.25 611.925V742.633H1537.92V608.347C1537.92 591.208 1533.67 578.376 1525.31 569.68L1525.31 569.674L1525.3 569.668C1516.93 560.664 1505.52 556.122 1490.93 556.122C1480.72 556.122 1471.72 558.469 1463.89 563.138L1463.88 563.144C1456.36 567.511 1450.41 573.922 1446.05 582.415L1446.05 582.422L1446.04 582.428C1441.69 590.602 1439.5 600.423 1439.5 611.925V742.633H1395.72V521.919H1435.05V554.803C1439.92 544.379 1447.91 535.465 1458.37 528.356C1470.71 519.875 1485.58 515.657 1502.93 515.657C1522.37 515.657 1538.61 520.931 1551.55 531.538C1560.38 538.771 1567.1 547.628 1571.72 558.091C1576.05 547.619 1582.83 538.757 1592.07 531.524C1605.61 520.93 1622.28 515.657 1642.01 515.657ZM1343.49 452C1351.45 452 1358.23 454.786 1363.75 460.346C1369.27 465.905 1372.04 472.721 1372.04 480.73C1372.04 488.452 1369.27 495.254 1363.77 501.096L1363.76 501.105L1363.75 501.115C1358.23 506.675 1351.45 509.461 1343.49 509.461C1335.81 509.461 1329.05 506.669 1323.25 501.134L1323.23 501.115L1323.21 501.096C1317.71 495.254 1314.94 488.452 1314.94 480.73C1314.94 472.721 1317.7 465.905 1323.23 460.346L1323.24 460.337L1323.25 460.327C1329.05 454.792 1335.81 452 1343.49 452Z'
fill='#fafafa'
/>
</svg>
)
}
interface ModelsOgImageProps {
eyebrow: string
title: string
subtitle: string
pills?: string[]
domainLabel?: string
}
export async function createModelsOgImage({
eyebrow,
title,
subtitle,
pills = [],
domainLabel = 'sim.ai/models',
}: ModelsOgImageProps) {
const text = `${eyebrow}${title}${subtitle}${pills.join('')}${domainLabel}`
const [regularFontData, mediumFontData] = await Promise.all([
loadGoogleFont('Geist', '400', text),
loadGoogleFont('Geist', '500', text),
])
return new ImageResponse(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
padding: '56px 64px',
background: '#121212',
fontFamily: 'Geist',
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<span
style={{
fontSize: 22,
fontWeight: 500,
color: '#71717a',
letterSpacing: '-0.01em',
}}
>
{eyebrow}
</span>
<span
style={{
fontSize: getTitleFontSize(title),
fontWeight: 500,
color: '#fafafa',
lineHeight: 1.08,
letterSpacing: '-0.03em',
maxWidth: '1000px',
}}
>
{title}
</span>
<span
style={{
fontSize: 28,
fontWeight: 400,
color: '#a1a1aa',
lineHeight: 1.35,
maxWidth: '980px',
}}
>
{subtitle}
</span>
{pills.length > 0 ? (
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginTop: 4 }}>
{pills.slice(0, 4).map((pill) => (
<div
key={pill}
style={{
display: 'flex',
alignItems: 'center',
borderRadius: 9999,
border: '1px solid #2f2f2f',
background: '#1b1b1b',
padding: '10px 16px',
color: '#d4d4d8',
fontSize: 20,
fontWeight: 500,
}}
>
{pill}
</div>
))}
</div>
) : null}
</div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
}}
>
<SimLogoFull />
<span
style={{
fontSize: 20,
fontWeight: 400,
color: '#71717a',
}}
>
{domainLabel}
</span>
</div>
</div>,
{
...size,
fonts: [
...(regularFontData
? [
{
name: 'Geist',
data: regularFontData,
style: 'normal' as const,
weight: 400 as const,
},
]
: []),
...(mediumFontData
? [
{
name: 'Geist',
data: mediumFontData,
style: 'normal' as const,
weight: 500 as const,
},
]
: []),
],
}
)
}

View File

@@ -0,0 +1,29 @@
import { createModelsOgImage } from '@/app/(landing)/models/og-utils'
import {
formatTokenCount,
MAX_CONTEXT_WINDOW,
TOTAL_MODEL_PROVIDERS,
TOTAL_MODELS,
} from '@/app/(landing)/models/utils'
export const runtime = 'edge'
export const contentType = 'image/png'
export const size = {
width: 1200,
height: 630,
}
export default async function Image() {
return createModelsOgImage({
eyebrow: 'Sim model directory',
title: 'AI Models Directory',
subtitle:
'Browse tracked AI models with pricing, context windows, and workflow-ready capability details.',
pills: [
`${TOTAL_MODELS} models`,
`${TOTAL_MODEL_PROVIDERS} providers`,
`${formatTokenCount(MAX_CONTEXT_WINDOW)} max context`,
],
domainLabel: 'sim.ai/models',
})
}

View File

@@ -0,0 +1,293 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
import { ModelDirectory } from '@/app/(landing)/models/components/model-directory'
import { ModelCard, ProviderCard } from '@/app/(landing)/models/components/model-primitives'
import {
getPricingBounds,
MODEL_CATALOG_PROVIDERS,
MODEL_PROVIDERS_WITH_CATALOGS,
TOP_MODEL_PROVIDERS,
TOTAL_MODEL_PROVIDERS,
TOTAL_MODELS,
} from '@/app/(landing)/models/utils'
const baseUrl = getBaseUrl()
const faqItems = [
{
question: 'What is the Sim AI models directory?',
answer:
'The Sim AI models directory is a public catalog of the language models and providers tracked inside Sim. It shows provider coverage, model IDs, pricing per one million tokens, context windows, and supported capabilities such as reasoning controls, structured outputs, and deep research.',
},
{
question: 'Can I compare models from multiple providers in one place?',
answer:
'Yes. This page organizes every tracked model by provider and lets you search across providers, model names, and capabilities. You can quickly compare OpenAI, Anthropic, Google, xAI, Mistral, Groq, Cerebras, Fireworks, Bedrock, and more from a single directory.',
},
{
question: 'Are these model prices shown per million tokens?',
answer:
'Yes. Input, cached input, and output prices on this page are shown per one million tokens based on the provider metadata tracked in Sim.',
},
{
question: 'Does Sim support providers with dynamic model catalogs too?',
answer:
'Yes. Some providers such as OpenRouter, Fireworks, Ollama, and vLLM load their model lists dynamically at runtime. Those providers are still shown here even when their full public model list is not hard-coded into the catalog.',
},
]
export const metadata: Metadata = {
title: 'AI Models Directory',
description: `Browse ${TOTAL_MODELS}+ AI models across ${TOTAL_MODEL_PROVIDERS} providers. Compare pricing, context windows, and capabilities for OpenAI, Anthropic, Google, xAI, Mistral, Bedrock, Groq, and more.`,
keywords: [
'AI models directory',
'LLM model list',
'model pricing',
'context window comparison',
'OpenAI models',
'Anthropic models',
'Google Gemini models',
'xAI Grok models',
'Mistral models',
...TOP_MODEL_PROVIDERS.map((provider) => `${provider} models`),
],
openGraph: {
title: 'AI Models Directory | Sim',
description: `Explore ${TOTAL_MODELS}+ AI models across ${TOTAL_MODEL_PROVIDERS} providers with pricing, context windows, and capability details.`,
url: `${baseUrl}/models`,
type: 'website',
images: [
{
url: `${baseUrl}/models/opengraph-image`,
width: 1200,
height: 630,
alt: 'Sim AI Models Directory',
},
],
},
twitter: {
card: 'summary_large_image',
title: 'AI Models Directory | Sim',
description: `Search ${TOTAL_MODELS}+ AI models across ${TOTAL_MODEL_PROVIDERS} providers.`,
images: [{ url: `${baseUrl}/models/opengraph-image`, alt: 'Sim AI Models Directory' }],
},
alternates: {
canonical: `${baseUrl}/models`,
},
}
export default function ModelsPage() {
const flatModels = MODEL_CATALOG_PROVIDERS.flatMap((provider) =>
provider.models.map((model) => ({ provider, model }))
)
const featuredProviders = MODEL_PROVIDERS_WITH_CATALOGS.slice(0, 6)
const featuredModels = MODEL_PROVIDERS_WITH_CATALOGS.flatMap((provider) =>
provider.featuredModels[0] ? [{ provider, model: provider.featuredModels[0] }] : []
).slice(0, 6)
const heroProviders = ['openai', 'anthropic', 'azure-openai', 'google', 'bedrock']
.map((providerId) => MODEL_CATALOG_PROVIDERS.find((provider) => provider.id === providerId))
.filter(
(provider): provider is (typeof MODEL_CATALOG_PROVIDERS)[number] => provider !== undefined
)
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl },
{ '@type': 'ListItem', position: 2, name: 'Models', item: `${baseUrl}/models` },
],
}
const itemListJsonLd = {
'@context': 'https://schema.org',
'@type': 'ItemList',
name: 'Sim AI Models Directory',
description: `Directory of ${TOTAL_MODELS} AI models tracked in Sim across ${TOTAL_MODEL_PROVIDERS} providers.`,
url: `${baseUrl}/models`,
numberOfItems: TOTAL_MODELS,
itemListElement: flatModels.map(({ provider, model }, index) => {
const { lowPrice, highPrice } = getPricingBounds(model.pricing)
return {
'@type': 'ListItem',
position: index + 1,
item: {
'@type': 'Product',
name: model.displayName,
url: `${baseUrl}${model.href}`,
description: model.summary,
brand: provider.name,
category: 'AI language model',
offers: {
'@type': 'AggregateOffer',
priceCurrency: 'USD',
lowPrice: lowPrice.toString(),
highPrice: highPrice.toString(),
},
},
}
}),
}
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqItems.map((item) => ({
'@type': 'Question',
name: item.question,
acceptedAnswer: {
'@type': 'Answer',
text: item.answer,
},
})),
}
return (
<>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
/>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(itemListJsonLd) }}
/>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
/>
<div className='mx-auto max-w-[1280px] px-6 py-16 sm:px-8 md:px-12'>
<section aria-labelledby='models-heading' className='mb-14'>
<div className='max-w-[840px]'>
<p className='mb-3 text-[12px] text-[var(--landing-text-muted)] uppercase tracking-[0.16em]'>
Public model directory
</p>
<h1
id='models-heading'
className='text-balance font-[500] text-[40px] text-[var(--landing-text)] leading-tight sm:text-[56px]'
>
Browse AI models by provider, pricing, and capabilities
</h1>
<p className='mt-5 max-w-[760px] text-[18px] text-[var(--landing-text-muted)] leading-relaxed'>
Explore every model tracked in Sim across providers like{' '}
{heroProviders.map((provider, index, allProviders) => {
const Icon = provider.icon
return (
<span key={provider.id}>
<span className='inline-flex items-center gap-1 whitespace-nowrap align-[0.02em]'>
{Icon ? (
<span
aria-hidden='true'
className='relative top-[0.02em] inline-flex shrink-0 text-[var(--landing-text)]'
>
<Icon className='h-[0.82em] w-[0.82em]' />
</span>
) : null}
<span>{provider.name}</span>
</span>
{index < allProviders.length - 1 ? ', ' : ''}
</span>
)
})}
{
' and more. Compare model IDs, token pricing, context windows, and features such as reasoning, structured outputs, and deep research from one clean catalog.'
}
</p>
</div>
<div className='mt-8 flex flex-wrap gap-3'>
<a
href='https://sim.ai'
className='inline-flex h-[34px] items-center rounded-[6px] border border-[var(--white)] bg-[var(--white)] px-3 font-[430] text-[14px] text-[var(--landing-text-dark)] transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
>
Start building free
</a>
<Link
href='/integrations'
className='inline-flex h-[34px] items-center rounded-[6px] border border-[var(--landing-border-strong)] px-3 font-[430] text-[14px] text-[var(--landing-text)] transition-colors hover:bg-[var(--landing-bg-elevated)]'
>
Explore integrations
</Link>
</div>
</section>
<section aria-labelledby='providers-heading' className='mb-16'>
<div className='mb-6'>
<h2
id='providers-heading'
className='font-[500] text-[28px] text-[var(--landing-text)]'
>
Browse by provider
</h2>
<p className='mt-2 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
Each provider has its own generated SEO page with model lineup details, featured
models, provider FAQs, and internal links to individual model pages.
</p>
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3'>
{featuredProviders.map((provider) => (
<ProviderCard key={provider.id} provider={provider} />
))}
</div>
</section>
<section aria-labelledby='featured-models-heading' className='mb-16'>
<div className='mb-6'>
<h2
id='featured-models-heading'
className='font-[500] text-[28px] text-[var(--landing-text)]'
>
Featured model pages
</h2>
<p className='mt-2 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
These pages are generated directly from the model registry and target high-intent
search queries around pricing, context windows, and model capabilities.
</p>
</div>
<div className='grid grid-cols-1 gap-4 xl:grid-cols-2'>
{featuredModels.map(({ provider, model }) => (
<ModelCard key={model.id} provider={provider} model={model} showProvider />
))}
</div>
</section>
<section aria-labelledby='all-models-heading'>
<div className='mb-6'>
<h2
id='all-models-heading'
className='font-[500] text-[28px] text-[var(--landing-text)]'
>
All models
</h2>
<p className='mt-2 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
Search the full catalog by provider, model ID, or capability. Use it to compare
providers, sanity-check pricing, and quickly understand which models fit the workflow
you&apos;re building. All pricing is shown per one million tokens using the metadata
currently tracked in Sim.
</p>
</div>
<ModelDirectory />
</section>
<section
aria-labelledby='faq-heading'
className='mt-16 rounded-3xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-6 sm:p-8'
>
<h2 id='faq-heading' className='font-[500] text-[28px] text-[var(--landing-text)]'>
Frequently asked questions
</h2>
<div className='mt-3'>
<LandingFAQ faqs={faqItems} />
</div>
</section>
</div>
</>
)
}

View File

@@ -0,0 +1,790 @@
import type { ComponentType } from 'react'
import { type ModelCapabilities, PROVIDER_DEFINITIONS } from '@/providers/models'
const PROVIDER_PREFIXES: Record<string, string[]> = {
'azure-openai': ['azure/'],
'azure-anthropic': ['azure-anthropic/'],
vertex: ['vertex/'],
bedrock: ['bedrock/'],
cerebras: ['cerebras/'],
fireworks: ['fireworks/'],
groq: ['groq/'],
openrouter: ['openrouter/'],
vllm: ['vllm/'],
}
const PROVIDER_NAME_OVERRIDES: Record<string, string> = {
deepseek: 'DeepSeek',
vllm: 'vLLM',
xai: 'xAI',
}
const TOKEN_REPLACEMENTS: Record<string, string> = {
ai: 'AI',
aws: 'AWS',
gpt: 'GPT',
oss: 'OSS',
llm: 'LLM',
xai: 'xAI',
openai: 'OpenAI',
anthropic: 'Anthropic',
azure: 'Azure',
gemini: 'Gemini',
vertex: 'Vertex',
groq: 'Groq',
mistral: 'Mistral',
deepseek: 'DeepSeek',
cerebras: 'Cerebras',
ollama: 'Ollama',
bedrock: 'Bedrock',
google: 'Google',
moonshotai: 'Moonshot AI',
qwen: 'Qwen',
glm: 'GLM',
kimi: 'Kimi',
nova: 'Nova',
llama: 'Llama',
meta: 'Meta',
cohere: 'Cohere',
amazon: 'Amazon',
opus: 'Opus',
sonnet: 'Sonnet',
haiku: 'Haiku',
flash: 'Flash',
preview: 'Preview',
latest: 'Latest',
mini: 'Mini',
nano: 'Nano',
pro: 'Pro',
plus: 'Plus',
plusplus: 'PlusPlus',
code: 'Code',
codex: 'Codex',
instant: 'Instant',
versatile: 'Versatile',
instruct: 'Instruct',
guard: 'Guard',
safeguard: 'Safeguard',
medium: 'Medium',
small: 'Small',
large: 'Large',
lite: 'Lite',
premier: 'Premier',
premierer: 'Premier',
micro: 'Micro',
reasoning: 'Reasoning',
non: 'Non',
distill: 'Distill',
chat: 'Chat',
text: 'Text',
embedding: 'Embedding',
router: 'Router',
}
export interface PricingInfo {
input: number
cachedInput?: number
output: number
updatedAt: string
}
export interface CatalogFaq {
question: string
answer: string
}
export interface CapabilityFact {
label: string
value: string
}
export interface CatalogModel {
id: string
slug: string
href: string
displayName: string
shortId: string
providerId: string
providerName: string
providerSlug: string
contextWindow: number | null
pricing: PricingInfo
capabilities: ModelCapabilities
capabilityTags: string[]
summary: string
bestFor: string
searchText: string
}
export interface CatalogProvider {
id: string
slug: string
href: string
name: string
description: string
summary: string
defaultModel: string
defaultModelDisplayName: string
icon?: ComponentType<{ className?: string }>
contextInformationAvailable: boolean
providerCapabilityTags: string[]
modelCount: number
models: CatalogModel[]
featuredModels: CatalogModel[]
searchText: string
}
export function formatTokenCount(value?: number | null): string {
if (value == null) {
return 'Unknown'
}
if (value >= 1000000) {
return `${trimTrailingZeros((value / 1000000).toFixed(2))}M`
}
if (value >= 1000) {
return `${trimTrailingZeros((value / 1000).toFixed(0))}k`
}
return value.toLocaleString('en-US')
}
export function formatPrice(price?: number | null): string {
if (price === undefined || price === null) {
return 'N/A'
}
const maximumFractionDigits = price > 0 && price < 0.001 ? 4 : 3
return `$${trimTrailingZeros(
new Intl.NumberFormat('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits,
}).format(price)
)}`
}
export function formatUpdatedAt(date: string): string {
try {
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
}).format(new Date(date))
} catch {
return date
}
}
export function formatCapabilityBoolean(
value: boolean | undefined,
{
positive = 'Supported',
negative = 'Not supported',
}: {
positive?: string
negative?: string
} = {}
): string {
return value ? positive : negative
}
function trimTrailingZeros(value: string): string {
return value.replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1')
}
function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/--+/g, '-')
}
function getProviderPrefixes(providerId: string): string[] {
return PROVIDER_PREFIXES[providerId] ?? [`${providerId}/`]
}
function stripProviderPrefix(providerId: string, modelId: string): string {
for (const prefix of getProviderPrefixes(providerId)) {
if (modelId.startsWith(prefix)) {
return modelId.slice(prefix.length)
}
}
return modelId
}
function stripTechnicalSuffixes(value: string): string {
return value
.replace(/-\d{8}-v\d+:\d+$/i, '')
.replace(/-v\d+:\d+$/i, '')
.replace(/-\d{8}$/i, '')
}
function tokenizeModelName(value: string): string[] {
return value
.replace(/[./:_]+/g, '-')
.split('-')
.filter(Boolean)
}
function mergeVersionTokens(tokens: string[]): string[] {
const merged: string[] = []
for (let index = 0; index < tokens.length; index += 1) {
const current = tokens[index]
const next = tokens[index + 1]
if (/^\d{1,2}$/.test(current) && /^\d{1,2}$/.test(next)) {
merged.push(`${current}.${next}`)
index += 1
continue
}
merged.push(current)
}
return merged
}
function formatModelToken(token: string): string {
const normalized = token.toLowerCase()
if (TOKEN_REPLACEMENTS[normalized]) {
return TOKEN_REPLACEMENTS[normalized]
}
if (/^\d+b$/i.test(token)) {
return `${token.slice(0, -1)}B`
}
if (/^\d+e$/i.test(token)) {
return `${token.slice(0, -1)}E`
}
if (/^o\d+$/i.test(token)) {
return token.toLowerCase()
}
if (/^r\d+$/i.test(token)) {
return token.toUpperCase()
}
if (/^v\d+$/i.test(token)) {
return token.toUpperCase()
}
if (/^\d+\.\d+$/.test(token)) {
return token
}
if (/^[a-z]{3,}\d+$/i.test(token)) {
const [, prefix, version] = token.match(/^([a-z]{3,})(\d+)$/i) ?? []
if (prefix && version) {
return `${formatModelToken(prefix)} ${version}`
}
}
if (/^[a-z]\d+[a-z]$/i.test(token)) {
return token.toUpperCase()
}
if (/^\d+$/.test(token)) {
return token
}
return token.charAt(0).toUpperCase() + token.slice(1)
}
function formatModelDisplayName(providerId: string, modelId: string): string {
const shortId = stripProviderPrefix(providerId, modelId)
const normalized = stripTechnicalSuffixes(shortId)
const tokens = mergeVersionTokens(tokenizeModelName(normalized))
const displayName = tokens
.map(formatModelToken)
.join(' ')
.split(/\s+/)
.filter(
(word, index, words) => index === 0 || word.toLowerCase() !== words[index - 1].toLowerCase()
)
.join(' ')
return displayName.replace(/^GPT (\d[\w.]*)/i, 'GPT-$1').replace(/\bGpt\b/g, 'GPT')
}
function buildCapabilityTags(capabilities: ModelCapabilities): string[] {
const tags: string[] = []
if (capabilities.temperature) {
tags.push(`Temperature ${capabilities.temperature.min}-${capabilities.temperature.max}`)
}
if (capabilities.toolUsageControl) {
tags.push('Tool choice')
}
if (capabilities.nativeStructuredOutputs) {
tags.push('Structured outputs')
}
if (capabilities.computerUse) {
tags.push('Computer use')
}
if (capabilities.deepResearch) {
tags.push('Deep research')
}
if (capabilities.reasoningEffort) {
tags.push(`Reasoning ${capabilities.reasoningEffort.values.join(', ')}`)
}
if (capabilities.verbosity) {
tags.push(`Verbosity ${capabilities.verbosity.values.join(', ')}`)
}
if (capabilities.thinking) {
tags.push(`Thinking ${capabilities.thinking.levels.join(', ')}`)
}
if (capabilities.maxOutputTokens) {
tags.push(`Max output ${formatTokenCount(capabilities.maxOutputTokens)}`)
}
if (capabilities.memory === false) {
tags.push('Memory off')
}
return tags
}
function buildBestForLine(model: {
pricing: PricingInfo
capabilities: ModelCapabilities
contextWindow: number | null
}): string {
const { pricing, capabilities, contextWindow } = model
if (capabilities.deepResearch) {
return 'Best for multi-step research workflows and agent-led web investigation.'
}
if (capabilities.reasoningEffort || capabilities.thinking) {
return 'Best for reasoning-heavy tasks that need more deliberate model control.'
}
if (pricing.input <= 0.2 && pricing.output <= 1.25) {
return 'Best for cost-sensitive automations, background tasks, and high-volume workloads.'
}
if (contextWindow && contextWindow >= 1000000) {
return 'Best for long-context retrieval, large documents, and high-memory workflows.'
}
if (capabilities.nativeStructuredOutputs) {
return 'Best for production workflows that need reliable typed outputs.'
}
return 'Best for general-purpose AI workflows inside Sim.'
}
function buildModelSummary(
providerName: string,
displayName: string,
pricing: PricingInfo,
contextWindow: number | null,
capabilityTags: string[]
): string {
const parts = [
`${displayName} is a ${providerName} model tracked in Sim.`,
contextWindow ? `It supports a ${formatTokenCount(contextWindow)} token context window.` : null,
`Pricing starts at ${formatPrice(pricing.input)}/1M input tokens and ${formatPrice(pricing.output)}/1M output tokens.`,
capabilityTags.length > 0
? `Key capabilities include ${capabilityTags.slice(0, 3).join(', ')}.`
: null,
]
return parts.filter(Boolean).join(' ')
}
function getProviderDisplayName(providerId: string, providerName: string): string {
return PROVIDER_NAME_OVERRIDES[providerId] ?? providerName
}
function computeModelRelevanceScore(model: CatalogModel): number {
return (
(model.capabilities.reasoningEffort ? 10 : 0) +
(model.capabilities.thinking ? 10 : 0) +
(model.capabilities.deepResearch ? 8 : 0) +
(model.capabilities.nativeStructuredOutputs ? 4 : 0) +
(model.contextWindow ?? 0) / 100000
)
}
function compareModelsByRelevance(a: CatalogModel, b: CatalogModel): number {
return computeModelRelevanceScore(b) - computeModelRelevanceScore(a)
}
const rawProviders = Object.values(PROVIDER_DEFINITIONS).map((provider) => {
const providerSlug = slugify(provider.id)
const providerDisplayName = getProviderDisplayName(provider.id, provider.name)
const providerCapabilityTags = buildCapabilityTags(provider.capabilities ?? {})
const models: CatalogModel[] = provider.models.map((model) => {
const shortId = stripProviderPrefix(provider.id, model.id)
const mergedCapabilities = { ...provider.capabilities, ...model.capabilities }
const capabilityTags = buildCapabilityTags(mergedCapabilities)
const displayName = formatModelDisplayName(provider.id, model.id)
const modelSlug = slugify(shortId)
const href = `/models/${providerSlug}/${modelSlug}`
return {
id: model.id,
slug: modelSlug,
href,
displayName,
shortId,
providerId: provider.id,
providerName: providerDisplayName,
providerSlug,
contextWindow: model.contextWindow ?? null,
pricing: model.pricing,
capabilities: mergedCapabilities,
capabilityTags,
summary: buildModelSummary(
providerDisplayName,
displayName,
model.pricing,
model.contextWindow ?? null,
capabilityTags
),
bestFor: buildBestForLine({
pricing: model.pricing,
capabilities: mergedCapabilities,
contextWindow: model.contextWindow ?? null,
}),
searchText: [
provider.name,
providerDisplayName,
provider.id,
provider.description,
model.id,
shortId,
displayName,
capabilityTags.join(' '),
]
.filter(Boolean)
.join(' ')
.toLowerCase(),
}
})
const defaultModelDisplayName =
models.find((model) => model.id === provider.defaultModel)?.displayName ||
(provider.defaultModel ? formatModelDisplayName(provider.id, provider.defaultModel) : 'Dynamic')
const featuredModels = [...models].sort(compareModelsByRelevance).slice(0, 6)
return {
id: provider.id,
slug: providerSlug,
href: `/models/${providerSlug}`,
name: providerDisplayName,
description: provider.description,
summary: `${providerDisplayName} has ${models.length} tracked model${models.length === 1 ? '' : 's'} in Sim with pricing, context window, and capability metadata.`,
defaultModel: provider.defaultModel,
defaultModelDisplayName,
icon: provider.icon,
contextInformationAvailable: provider.contextInformationAvailable !== false,
providerCapabilityTags,
modelCount: models.length,
models,
featuredModels,
searchText: [
provider.name,
providerDisplayName,
provider.id,
provider.description,
provider.defaultModel,
defaultModelDisplayName,
providerCapabilityTags.join(' '),
models.map((model) => model.displayName).join(' '),
]
.filter(Boolean)
.join(' ')
.toLowerCase(),
} satisfies CatalogProvider
})
function assertUniqueGeneratedRoutes(providers: CatalogProvider[]): void {
const seenProviderHrefs = new Map<string, string>()
const seenModelHrefs = new Map<string, string>()
for (const provider of providers) {
const existingProvider = seenProviderHrefs.get(provider.href)
if (existingProvider) {
throw new Error(
`Duplicate provider route detected: ${provider.href} for ${provider.id} and ${existingProvider}`
)
}
seenProviderHrefs.set(provider.href, provider.id)
for (const model of provider.models) {
const existingModel = seenModelHrefs.get(model.href)
if (existingModel) {
throw new Error(
`Duplicate model route detected: ${model.href} for ${model.id} and ${existingModel}`
)
}
seenModelHrefs.set(model.href, model.id)
}
}
}
assertUniqueGeneratedRoutes(rawProviders)
export const MODEL_CATALOG_PROVIDERS: CatalogProvider[] = rawProviders
export const MODEL_PROVIDERS_WITH_CATALOGS = MODEL_CATALOG_PROVIDERS.filter(
(provider) => provider.models.length > 0
)
export const MODEL_PROVIDERS_WITH_DYNAMIC_CATALOGS = MODEL_CATALOG_PROVIDERS.filter(
(provider) => provider.models.length === 0
)
export const ALL_CATALOG_MODELS = MODEL_PROVIDERS_WITH_CATALOGS.flatMap(
(provider) => provider.models
)
export const TOTAL_MODEL_PROVIDERS = MODEL_CATALOG_PROVIDERS.length
export const TOTAL_MODELS = ALL_CATALOG_MODELS.length
export const MAX_CONTEXT_WINDOW = Math.max(
...ALL_CATALOG_MODELS.map((model) => model.contextWindow ?? 0)
)
export const TOP_MODEL_PROVIDERS = MODEL_PROVIDERS_WITH_CATALOGS.slice(0, 8).map(
(provider) => provider.name
)
export function getPricingBounds(pricing: PricingInfo): { lowPrice: number; highPrice: number } {
return {
lowPrice: Math.min(
pricing.input,
pricing.output,
...(pricing.cachedInput !== undefined ? [pricing.cachedInput] : [])
),
highPrice: Math.max(pricing.input, pricing.output),
}
}
export function getProviderBySlug(providerSlug: string): CatalogProvider | null {
return MODEL_CATALOG_PROVIDERS.find((provider) => provider.slug === providerSlug) ?? null
}
export function getModelBySlug(providerSlug: string, modelSlug: string): CatalogModel | null {
const provider = getProviderBySlug(providerSlug)
if (!provider) {
return null
}
return provider.models.find((model) => model.slug === modelSlug) ?? null
}
export function getRelatedModels(targetModel: CatalogModel, limit = 6): CatalogModel[] {
const provider = MODEL_PROVIDERS_WITH_CATALOGS.find(
(entry) => entry.id === targetModel.providerId
)
if (!provider) {
return []
}
const targetTokens = new Set(tokenizeModelName(stripTechnicalSuffixes(targetModel.shortId)))
return provider.models
.filter((model) => model.id !== targetModel.id)
.map((model) => {
const modelTokens = tokenizeModelName(stripTechnicalSuffixes(model.shortId))
const sharedTokenCount = modelTokens.filter((token) => targetTokens.has(token)).length
const sharedCapabilityCount = model.capabilityTags.filter((tag) =>
targetModel.capabilityTags.includes(tag)
).length
return {
model,
score: sharedTokenCount * 2 + sharedCapabilityCount + (model.contextWindow ?? 0) / 1000000,
}
})
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map(({ model }) => model)
}
export function buildProviderFaqs(provider: CatalogProvider): CatalogFaq[] {
const cheapestModel = getCheapestProviderModel(provider)
const largestContextModel = getLargestContextProviderModel(provider)
return [
{
question: `What ${provider.name} models are available in Sim?`,
answer: `Sim currently tracks ${provider.modelCount} ${provider.name} model${provider.modelCount === 1 ? '' : 's'} including ${provider.models
.slice(0, 6)
.map((model) => model.displayName)
.join(', ')}${provider.modelCount > 6 ? ', and more' : ''}.`,
},
{
question: `What is the default ${provider.name} model in Sim?`,
answer: provider.defaultModel
? `${provider.defaultModelDisplayName} is the default ${provider.name} model in Sim.`
: `${provider.name} does not have a fixed default model in the public catalog because models are loaded dynamically.`,
},
{
question: `What is the cheapest ${provider.name} model tracked in Sim?`,
answer: cheapestModel
? `${cheapestModel.displayName} currently has the lowest listed input price at ${formatPrice(
cheapestModel.pricing.input
)}/1M tokens.`
: `Sim does not currently expose a fixed public pricing table for ${provider.name} models on this page.`,
},
{
question: `Which ${provider.name} model has the largest context window?`,
answer: largestContextModel?.contextWindow
? `${largestContextModel.displayName} currently has the largest listed context window at ${formatTokenCount(
largestContextModel.contextWindow
)} tokens.`
: `Context window details are not fully available for every ${provider.name} model in the public catalog.`,
},
]
}
export function buildModelFaqs(provider: CatalogProvider, model: CatalogModel): CatalogFaq[] {
return [
{
question: `What is ${model.displayName}?`,
answer: `${model.displayName} is a ${provider.name} model available in Sim. ${model.summary}`,
},
{
question: `How much does ${model.displayName} cost?`,
answer: `${model.displayName} is listed at ${formatPrice(model.pricing.input)}/1M input tokens${model.pricing.cachedInput !== undefined ? `, ${formatPrice(model.pricing.cachedInput)}/1M cached input tokens` : ''}, and ${formatPrice(model.pricing.output)}/1M output tokens.`,
},
{
question: `What is the context window for ${model.displayName}?`,
answer: model.contextWindow
? `${model.displayName} supports a listed context window of ${formatTokenCount(model.contextWindow)} tokens in Sim.`
: `A public context window value is not currently tracked for ${model.displayName}.`,
},
{
question: `What capabilities does ${model.displayName} support?`,
answer:
model.capabilityTags.length > 0
? `${model.displayName} supports ${model.capabilityTags.join(', ')}.`
: `${model.displayName} is available in Sim, but no extra public capability flags are currently tracked for this model.`,
},
]
}
export function buildModelCapabilityFacts(model: CatalogModel): CapabilityFact[] {
const { capabilities } = model
return [
{
label: 'Temperature',
value: capabilities.temperature
? `${capabilities.temperature.min} to ${capabilities.temperature.max}`
: 'Not configurable',
},
{
label: 'Reasoning effort',
value: capabilities.reasoningEffort
? capabilities.reasoningEffort.values.join(', ')
: 'Not supported',
},
{
label: 'Verbosity',
value: capabilities.verbosity ? capabilities.verbosity.values.join(', ') : 'Not supported',
},
{
label: 'Thinking levels',
value: capabilities.thinking
? `${capabilities.thinking.levels.join(', ')}${
capabilities.thinking.default ? ` (default: ${capabilities.thinking.default})` : ''
}`
: 'Not supported',
},
{
label: 'Structured outputs',
value: formatCapabilityBoolean(capabilities.nativeStructuredOutputs),
},
{
label: 'Tool choice',
value: formatCapabilityBoolean(capabilities.toolUsageControl),
},
{
label: 'Computer use',
value: formatCapabilityBoolean(capabilities.computerUse),
},
{
label: 'Deep research',
value: formatCapabilityBoolean(capabilities.deepResearch),
},
{
label: 'Memory support',
value: capabilities.memory === false ? 'Disabled' : 'Supported',
},
{
label: 'Max output tokens',
value: capabilities.maxOutputTokens
? formatTokenCount(capabilities.maxOutputTokens)
: 'Standard defaults',
},
]
}
export function getCheapestProviderModel(provider: CatalogProvider): CatalogModel | null {
return [...provider.models].sort((a, b) => a.pricing.input - b.pricing.input)[0] ?? null
}
export function getLargestContextProviderModel(provider: CatalogProvider): CatalogModel | null {
return (
[...provider.models].sort((a, b) => (b.contextWindow ?? 0) - (a.contextWindow ?? 0))[0] ?? null
)
}
export function getProviderCapabilitySummary(provider: CatalogProvider): CapabilityFact[] {
const reasoningCount = provider.models.filter(
(model) => model.capabilities.reasoningEffort || model.capabilities.thinking
).length
const structuredCount = provider.models.filter(
(model) => model.capabilities.nativeStructuredOutputs
).length
const deepResearchCount = provider.models.filter(
(model) => model.capabilities.deepResearch
).length
const cheapestModel = getCheapestProviderModel(provider)
const largestContextModel = getLargestContextProviderModel(provider)
return [
{
label: 'Reasoning-capable models',
value: reasoningCount > 0 ? `${reasoningCount} tracked` : 'None tracked',
},
{
label: 'Structured outputs',
value: structuredCount > 0 ? `${structuredCount} tracked` : 'None tracked',
},
{
label: 'Deep research models',
value: deepResearchCount > 0 ? `${deepResearchCount} tracked` : 'None tracked',
},
{
label: 'Lowest input price',
value: cheapestModel
? `${cheapestModel.displayName} at ${formatPrice(cheapestModel.pricing.input)}/1M`
: 'Not available',
},
{
label: 'Largest context window',
value: largestContextModel?.contextWindow
? `${largestContextModel.displayName} at ${formatTokenCount(largestContextModel.contextWindow)}`
: 'Not available',
},
]
}

View File

@@ -0,0 +1,292 @@
import type { Metadata } from 'next'
import { getNavBlogPosts } from '@/lib/blog/registry'
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
import { season } from '@/app/_styles/fonts/season/season'
import Footer from '@/app/(home)/components/footer/footer'
import Navbar from '@/app/(home)/components/navbar/navbar'
export const metadata: Metadata = {
title: 'Partner Program',
description:
'Join the Sim partner program. Build, deploy, and sell AI workflow solutions. Earn your certification through Sim Academy.',
metadataBase: new URL('https://sim.ai'),
openGraph: {
title: 'Partner Program | Sim',
description: 'Join the Sim partner program.',
type: 'website',
},
}
const PARTNER_TIERS = [
{
name: 'Certified Partner',
badge: 'Entry',
color: '#3A3A3A',
requirements: ['Complete Sim Academy certification', 'Deploy at least 1 live workflow'],
perks: [
'Official partner badge',
'Listed in partner directory',
'Early access to new features',
],
},
{
name: 'Silver Partner',
badge: 'Growth',
color: '#5A5A5A',
requirements: [
'All Certified requirements',
'3+ active client deployments',
'Sim Academy advanced certification',
],
perks: [
'All Certified perks',
'Dedicated partner Slack channel',
'Co-marketing opportunities',
'Priority support',
],
},
{
name: 'Gold Partner',
badge: 'Premier',
color: '#8B7355',
requirements: [
'All Silver requirements',
'10+ active client deployments',
'Sim solutions architect certification',
],
perks: [
'All Silver perks',
'Revenue share program',
'Joint case studies',
'Dedicated partner success manager',
'Influence product roadmap',
],
},
]
const HOW_IT_WORKS = [
{
step: '01',
title: 'Sign up & complete Sim Academy',
description:
'Create an account and work through the Sim Academy certification program. Learn to build, integrate, and deploy AI workflows through hands-on canvas exercises.',
},
{
step: '02',
title: 'Build & deploy real solutions',
description:
'Put your skills to work. Build workflow automations for clients, integrate Sim into existing products, or create your own Sim-powered applications.',
},
{
step: '03',
title: 'Get certified & grow',
description:
'Earn your partner certification and unlock perks, co-marketing opportunities, and revenue share as you scale your practice.',
},
]
const BENEFITS = [
{
icon: '🎓',
title: 'Interactive Learning',
description:
'Learn on the real Sim canvas with drag-and-drop exercises, instant feedback, and guided exercises — not just videos.',
},
{
icon: '🤝',
title: 'Co-Marketing',
description:
'Get listed in the Sim partner directory, featured in case studies, and promoted to the Sim user base.',
},
{
icon: '💰',
title: 'Revenue Share',
description: 'Gold partners earn revenue share on referred customers and managed deployments.',
},
{
icon: '🚀',
title: 'Early Access',
description:
'Partners get early access to new Sim features, APIs, and integrations before they launch publicly.',
},
{
icon: '🛠️',
title: 'Technical Support',
description:
'Priority technical support, private Slack access, and a dedicated partner success manager for Gold partners.',
},
{
icon: '📣',
title: 'Community',
description:
'Join a growing community of Sim builders. Share workflows, collaborate on solutions, and shape the product roadmap.',
},
]
export default async function PartnersPage() {
const blogPosts = await getNavBlogPosts()
return (
<div
className={`${season.variable} ${martianMono.variable} min-h-screen bg-[#1C1C1C] font-[430] font-season text-[#ECECEC]`}
>
<header>
<Navbar logoOnly={false} blogPosts={blogPosts} />
</header>
<main>
{/* Hero */}
<section className='border-[#2A2A2A] border-b px-[80px] py-[100px]'>
<div className='mx-auto max-w-4xl'>
<div className='mb-4 text-[#666] text-[13px] uppercase tracking-[0.12em]'>
Partner Program
</div>
<h1 className='mb-5 text-[64px] text-white leading-[105%] tracking-[-0.03em]'>
Build the future
<br />
of AI automation
</h1>
<p className='mb-10 max-w-xl text-[#F6F6F0]/60 text-[18px] leading-[160%] tracking-[0.01em]'>
Become a certified Sim partner. Complete Sim Academy, deploy real solutions, and earn
recognition in the growing ecosystem of AI workflow builders.
</p>
<div className='flex items-center gap-4'>
{/* TODO: Uncomment when academy is public */}
{/* <Link
href='/academy'
className='inline-flex h-[44px] items-center rounded-[5px] bg-white px-6 text-[#1C1C1C] text-[15px] transition-colors hover:bg-[#E8E8E8]'
>
Start Sim Academy →
</Link> */}
<a
href='#how-it-works'
className='inline-flex h-[44px] items-center rounded-[5px] border border-[#3A3A3A] px-6 text-[#ECECEC] text-[15px] transition-colors hover:border-[#4A4A4A]'
>
Learn more
</a>
</div>
</div>
</section>
{/* Benefits grid */}
<section className='border-[#2A2A2A] border-b px-[80px] py-20'>
<div className='mx-auto max-w-5xl'>
<div className='mb-12 text-[#666] text-[13px] uppercase tracking-[0.12em]'>
Why partner with Sim
</div>
<div className='grid gap-6 sm:grid-cols-2 lg:grid-cols-3'>
{BENEFITS.map((b) => (
<div key={b.title} className='rounded-[8px] border border-[#2A2A2A] bg-[#222] p-6'>
<div className='mb-3 text-[24px]'>{b.icon}</div>
<h3 className='mb-2 text-[#ECECEC] text-[15px]'>{b.title}</h3>
<p className='text-[#999] text-[14px] leading-[160%]'>{b.description}</p>
</div>
))}
</div>
</div>
</section>
{/* How it works */}
<section id='how-it-works' className='border-[#2A2A2A] border-b px-[80px] py-20'>
<div className='mx-auto max-w-4xl'>
<div className='mb-12 text-[#666] text-[13px] uppercase tracking-[0.12em]'>
How it works
</div>
<div className='space-y-10'>
{HOW_IT_WORKS.map((step) => (
<div key={step.step} className='flex gap-8'>
<div className='flex-shrink-0 font-[430] text-[#2A2A2A] text-[48px] leading-none'>
{step.step}
</div>
<div className='pt-2'>
<h3 className='mb-2 text-[#ECECEC] text-[18px]'>{step.title}</h3>
<p className='text-[#999] text-[15px] leading-[160%]'>{step.description}</p>
</div>
</div>
))}
</div>
</div>
</section>
{/* Partner tiers */}
<section className='border-[#2A2A2A] border-b px-[80px] py-20'>
<div className='mx-auto max-w-5xl'>
<div className='mb-12 text-[#666] text-[13px] uppercase tracking-[0.12em]'>
Partner tiers
</div>
<div className='grid gap-5 lg:grid-cols-3'>
{PARTNER_TIERS.map((tier) => (
<div
key={tier.name}
className='flex flex-col rounded-[8px] border border-[#2A2A2A] bg-[#222] p-6'
>
<div className='mb-4 flex items-center justify-between'>
<h3 className='text-[#ECECEC] text-[16px]'>{tier.name}</h3>
<span
className='rounded-full px-2.5 py-0.5 text-[11px]'
style={{
backgroundColor: `${tier.color}33`,
color: tier.color === '#8B7355' ? '#C8A96E' : '#999',
border: `1px solid ${tier.color}`,
}}
>
{tier.badge}
</span>
</div>
<div className='mb-4'>
<p className='mb-2 text-[#555] text-[12px] uppercase tracking-[0.1em]'>
Requirements
</p>
<ul className='space-y-1.5'>
{tier.requirements.map((r) => (
<li key={r} className='flex items-start gap-2 text-[#999] text-[13px]'>
<span className='mt-1.5 h-1 w-1 flex-shrink-0 rounded-full bg-[#555]' />
{r}
</li>
))}
</ul>
</div>
<div className='mt-auto'>
<p className='mb-2 text-[#555] text-[12px] uppercase tracking-[0.1em]'>Perks</p>
<ul className='space-y-1.5'>
{tier.perks.map((p) => (
<li key={p} className='flex items-start gap-2 text-[#ECECEC] text-[13px]'>
<span className='mt-1.5 h-1 w-1 flex-shrink-0 rounded-full bg-[#4CAF50]' />
{p}
</li>
))}
</ul>
</div>
</div>
))}
</div>
</div>
</section>
{/* CTA */}
<section className='px-[80px] py-[100px]'>
<div className='mx-auto max-w-3xl text-center'>
<h2 className='mb-4 text-[48px] text-white leading-[110%] tracking-[-0.02em]'>
Ready to get started?
</h2>
<p className='mb-10 text-[#F6F6F0]/60 text-[18px] leading-[160%]'>
Complete Sim Academy to earn your first certification and unlock partner benefits.
It's free to start — no credit card required.
</p>
{/* TODO: Uncomment when academy is public */}
{/* <Link
href='/academy'
className='inline-flex h-[48px] items-center rounded-[5px] bg-white px-8 font-[430] text-[#1C1C1C] text-[15px] transition-colors hover:bg-[#E8E8E8]'
>
Start Sim Academy
</Link> */}
</div>
</section>
</main>
<Footer />
</div>
)
}

View File

@@ -25,6 +25,10 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
pathname.startsWith('/form') ||
pathname.startsWith('/oauth')
const isDarkModePage = pathname.startsWith('/academy')
const forcedTheme = isLightModePage ? 'light' : isDarkModePage ? 'dark' : undefined
return (
<NextThemesProvider
attribute='class'
@@ -32,7 +36,7 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
enableSystem
disableTransitionOnChange
storageKey='sim-theme'
forcedTheme={isLightModePage ? 'light' : undefined}
forcedTheme={forcedTheme}
{...props}
>
{children}

View File

@@ -15,6 +15,12 @@
--toolbar-triggers-height: 300px; /* TOOLBAR_TRIGGERS_HEIGHT.DEFAULT */
--editor-connections-height: 172px; /* EDITOR_CONNECTIONS_HEIGHT.DEFAULT */
--terminal-height: 206px; /* TERMINAL_HEIGHT.DEFAULT */
--auth-primary-btn-bg: #ffffff;
--auth-primary-btn-border: #ffffff;
--auth-primary-btn-text: #000000;
--auth-primary-btn-hover-bg: #e0e0e0;
--auth-primary-btn-hover-border: #e0e0e0;
--auth-primary-btn-hover-text: #000000;
/* z-index scale for layered UI
Popover must be above modal so dropdowns inside modals render correctly */

View File

@@ -0,0 +1,156 @@
'use client'
import { useEffect, useState } from 'react'
import { CheckCircle2, Circle, ExternalLink, GraduationCap, Loader2 } from 'lucide-react'
import Link from 'next/link'
import { getCompletedLessons } from '@/lib/academy/local-progress'
import type { Course } from '@/lib/academy/types'
import { useSession } from '@/lib/auth/auth-client'
import { useCourseCertificate, useIssueCertificate } from '@/hooks/queries/academy'
interface CourseProgressProps {
course: Course
courseSlug: string
}
export function CourseProgress({ course, courseSlug }: CourseProgressProps) {
// Start with an empty set so SSR and initial client render match, then hydrate from localStorage.
const [completedIds, setCompletedIds] = useState<Set<string>>(() => new Set())
useEffect(() => {
setCompletedIds(getCompletedLessons())
}, [])
const { data: session } = useSession()
const { data: fetchedCert } = useCourseCertificate(session ? course.id : undefined)
const { mutate: issueCertificate, isPending, data: issuedCert, error } = useIssueCertificate()
const certificate = fetchedCert ?? issuedCert
const allLessons = course.modules.flatMap((m) => m.lessons)
const totalLessons = allLessons.length
const completedCount = allLessons.filter((l) => completedIds.has(l.id)).length
const percentComplete = totalLessons > 0 ? Math.round((completedCount / totalLessons) * 100) : 0
return (
<>
{completedCount > 0 && (
<div className='px-4 pt-8 sm:px-8 md:px-[80px]'>
<div className='mx-auto max-w-3xl rounded-[8px] border border-[#2A2A2A] bg-[#222] p-4'>
<div className='mb-2 flex items-center justify-between text-[13px]'>
<span className='text-[#999]'>Your progress</span>
<span className='text-[#ECECEC]'>
{completedCount}/{totalLessons} lessons
</span>
</div>
<div className='h-1.5 w-full overflow-hidden rounded-full bg-[#2A2A2A]'>
<div
className='h-full rounded-full bg-[#ECECEC] transition-all'
style={{ width: `${percentComplete}%` }}
/>
</div>
</div>
</div>
)}
<section className='px-4 py-14 sm:px-8 md:px-[80px]'>
<div className='mx-auto max-w-3xl space-y-10'>
{course.modules.map((mod, modIndex) => (
<div key={mod.id}>
<div className='mb-4 flex items-center gap-3'>
<span className='text-[#555] text-[12px]'>Module {modIndex + 1}</span>
<div className='h-px flex-1 bg-[#2A2A2A]' />
</div>
<h2 className='mb-4 font-[430] text-[#ECECEC] text-[18px]'>{mod.title}</h2>
<div className='space-y-2'>
{mod.lessons.map((lesson) => (
<Link
key={lesson.id}
href={`/academy/${courseSlug}/${lesson.slug}`}
className='flex items-center gap-3 rounded-[8px] border border-[#2A2A2A] bg-[#222] px-4 py-3 text-[14px] transition-colors hover:border-[#3A3A3A] hover:bg-[#272727]'
>
{completedIds.has(lesson.id) ? (
<CheckCircle2 className='h-4 w-4 flex-shrink-0 text-[#4CAF50]' />
) : (
<Circle className='h-4 w-4 flex-shrink-0 text-[#444]' />
)}
<span className='flex-1 text-[#ECECEC]'>{lesson.title}</span>
<span className='text-[#555] text-[12px] capitalize'>{lesson.lessonType}</span>
{lesson.videoDurationSeconds && (
<span className='text-[#555] text-[12px]'>
{Math.round(lesson.videoDurationSeconds / 60)} min
</span>
)}
</Link>
))}
</div>
</div>
))}
</div>
</section>
{totalLessons > 0 && completedCount === totalLessons && (
<section className='px-4 pb-16 sm:px-8 md:px-[80px]'>
<div className='mx-auto max-w-3xl rounded-[8px] border border-[#3A4A3A] bg-[#1F2A1F] p-6'>
{certificate ? (
<div className='flex items-center justify-between'>
<div className='flex items-center gap-3'>
<GraduationCap className='h-6 w-6 text-[#4CAF50]' />
<div>
<p className='font-[430] text-[#ECECEC] text-[15px]'>Certificate issued!</p>
<p className='font-mono text-[#666] text-[13px]'>
{certificate.certificateNumber}
</p>
</div>
</div>
<Link
href={`/academy/certificate/${certificate.certificateNumber}`}
className='flex items-center gap-1.5 rounded-[5px] bg-[#4CAF50] px-4 py-2 font-[430] text-[#1C1C1C] text-[13px] transition-colors hover:bg-[#5DBF61]'
>
View certificate
<ExternalLink className='h-3.5 w-3.5' />
</Link>
</div>
) : (
<div className='flex items-center justify-between'>
<div className='flex items-center gap-3'>
<GraduationCap className='h-6 w-6 text-[#4CAF50]' />
<div>
<p className='font-[430] text-[#ECECEC] text-[15px]'>Course Complete!</p>
<p className='text-[#666] text-[13px]'>
{session
? error
? 'Something went wrong. Try again.'
: 'Claim your certificate of completion.'
: 'Sign in to claim your certificate.'}
</p>
</div>
</div>
{session ? (
<button
type='button'
disabled={isPending}
onClick={() =>
issueCertificate({
courseId: course.id,
completedLessonIds: [...completedIds],
})
}
className='flex items-center gap-2 rounded-[5px] bg-[#ECECEC] px-4 py-2 font-[430] text-[#1C1C1C] text-[13px] transition-colors hover:bg-white disabled:opacity-50'
>
{isPending && <Loader2 className='h-3.5 w-3.5 animate-spin' />}
{isPending ? 'Issuing…' : 'Get certificate'}
</button>
) : (
<Link
href='/login'
className='rounded-[5px] bg-[#ECECEC] px-4 py-2 font-[430] text-[#1C1C1C] text-[13px] transition-colors hover:bg-white'
>
Sign in
</Link>
)}
</div>
)}
</div>
</section>
)}
</>
)
}

View File

@@ -0,0 +1,68 @@
import { Clock, GraduationCap } from 'lucide-react'
import type { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { COURSES, getCourse } from '@/lib/academy/content'
import { CourseProgress } from './components/course-progress'
interface CourseDetailPageProps {
params: Promise<{ courseSlug: string }>
}
export function generateStaticParams() {
return COURSES.map((course) => ({ courseSlug: course.slug }))
}
export async function generateMetadata({ params }: CourseDetailPageProps): Promise<Metadata> {
const { courseSlug } = await params
const course = getCourse(courseSlug)
if (!course) return { title: 'Course Not Found' }
return {
title: course.title,
description: course.description,
}
}
export default async function CourseDetailPage({ params }: CourseDetailPageProps) {
const { courseSlug } = await params
const course = getCourse(courseSlug)
if (!course) notFound()
return (
<main>
<section className='border-[#2A2A2A] border-b px-4 py-16 sm:px-8 md:px-[80px]'>
<div className='mx-auto max-w-3xl'>
<Link
href='/academy'
className='mb-4 inline-flex items-center gap-1.5 text-[#666] text-[13px] transition-colors hover:text-[#999]'
>
All courses
</Link>
<h1 className='mb-3 font-[430] text-[#ECECEC] text-[36px] leading-[115%] tracking-[-0.02em]'>
{course.title}
</h1>
{course.description && (
<p className='mb-6 text-[#F6F6F0]/60 text-[16px] leading-[160%]'>
{course.description}
</p>
)}
<div className='mt-6 flex items-center gap-5 text-[#666] text-[13px]'>
{course.estimatedMinutes && (
<span className='flex items-center gap-1.5'>
<Clock className='h-3.5 w-3.5' />
{course.estimatedMinutes} min total
</span>
)}
<span className='flex items-center gap-1.5'>
<GraduationCap className='h-3.5 w-3.5' />
Certificate upon completion
</span>
</div>
</div>
</section>
<CourseProgress course={course} courseSlug={courseSlug} />
</main>
)
}

View File

@@ -0,0 +1,127 @@
import { cache } from 'react'
import { db } from '@sim/db'
import { academyCertificate } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { CheckCircle2, GraduationCap, XCircle } from 'lucide-react'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import type { AcademyCertificate } from '@/lib/academy/types'
interface CertificatePageProps {
params: Promise<{ certificateNumber: string }>
}
export async function generateMetadata({ params }: CertificatePageProps): Promise<Metadata> {
const { certificateNumber } = await params
const certificate = await fetchCertificate(certificateNumber)
if (!certificate) return { title: 'Certificate Not Found' }
return {
title: `${certificate.metadata?.courseTitle ?? 'Certificate'} — Certificate`,
description: `Verified certificate of completion awarded to ${certificate.metadata?.recipientName ?? 'a recipient'}.`,
}
}
const fetchCertificate = cache(
async (certificateNumber: string): Promise<AcademyCertificate | null> => {
const [row] = await db
.select()
.from(academyCertificate)
.where(eq(academyCertificate.certificateNumber, certificateNumber))
.limit(1)
return (row as unknown as AcademyCertificate) ?? null
}
)
const DATE_FORMAT: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric' }
function formatDate(date: string | Date) {
return new Date(date).toLocaleDateString('en-US', DATE_FORMAT)
}
function MetaRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className='flex items-center justify-between px-5 py-3.5'>
<span className='text-[#666] text-[13px]'>{label}</span>
{children}
</div>
)
}
export default async function CertificatePage({ params }: CertificatePageProps) {
const { certificateNumber } = await params
const certificate = await fetchCertificate(certificateNumber)
if (!certificate) notFound()
return (
<main className='flex flex-1 items-center justify-center px-6 py-20'>
<div className='w-full max-w-2xl'>
<div className='rounded-[12px] border border-[#3A4A3A] bg-[#1C2A1C] p-10 text-center'>
<div className='mb-6 flex justify-center'>
<div className='flex h-16 w-16 items-center justify-center rounded-full border-2 border-[#4CAF50]/40 bg-[#4CAF50]/10'>
<GraduationCap className='h-8 w-8 text-[#4CAF50]' />
</div>
</div>
<div className='mb-2 text-[#4CAF50]/70 text-[13px] uppercase tracking-[0.12em]'>
Certificate of Completion
</div>
<h1 className='mb-1 font-[430] text-[#ECECEC] text-[28px] leading-[120%]'>
{certificate.metadata?.courseTitle}
</h1>
{certificate.metadata?.recipientName && (
<p className='mb-6 text-[#999] text-[16px]'>
Awarded to{' '}
<span className='text-[#ECECEC]'>{certificate.metadata.recipientName}</span>
</p>
)}
{certificate.status === 'active' ? (
<div className='flex items-center justify-center gap-2 text-[#4CAF50]'>
<CheckCircle2 className='h-4 w-4' />
<span className='font-[430] text-[14px]'>Verified</span>
</div>
) : (
<div className='flex items-center justify-center gap-2 text-[#f44336]'>
<XCircle className='h-4 w-4' />
<span className='font-[430] text-[14px] capitalize'>{certificate.status}</span>
</div>
)}
</div>
<div className='mt-6 divide-y divide-[#2A2A2A] rounded-[8px] border border-[#2A2A2A] bg-[#222]'>
<MetaRow label='Certificate number'>
<span className='font-mono text-[#ECECEC] text-[13px]'>
{certificate.certificateNumber}
</span>
</MetaRow>
<MetaRow label='Issued'>
<span className='text-[#ECECEC] text-[13px]'>{formatDate(certificate.issuedAt)}</span>
</MetaRow>
<MetaRow label='Status'>
<span
className={`text-[13px] capitalize ${
certificate.status === 'active' ? 'text-[#4CAF50]' : 'text-[#f44336]'
}`}
>
{certificate.status}
</span>
</MetaRow>
{certificate.expiresAt && (
<MetaRow label='Expires'>
<span className='text-[#ECECEC] text-[13px]'>
{formatDate(certificate.expiresAt)}
</span>
</MetaRow>
)}
</div>
<p className='mt-5 text-center text-[#555] text-[13px]'>
This certificate was issued by Sim AI, Inc. and verifies the holder has completed the{' '}
{certificate.metadata?.courseTitle} program.
</p>
</div>
</main>
)
}

View File

@@ -0,0 +1,16 @@
import type React from 'react'
import { getNavBlogPosts } from '@/lib/blog/registry'
import Footer from '@/app/(home)/components/footer/footer'
import Navbar from '@/app/(home)/components/navbar/navbar'
export default async function AcademyCatalogLayout({ children }: { children: React.ReactNode }) {
const blogPosts = await getNavBlogPosts()
return (
<>
<Navbar blogPosts={blogPosts} />
{children}
<Footer hideCTA />
</>
)
}

View File

@@ -0,0 +1,19 @@
import Link from 'next/link'
export default function AcademyNotFound() {
return (
<main className='flex flex-1 flex-col items-center justify-center px-6 py-32 text-center'>
<p className='mb-2 font-mono text-[#555] text-[13px] uppercase tracking-widest'>404</p>
<h1 className='mb-3 font-[430] text-[#ECECEC] text-[28px] leading-[120%]'>Page not found</h1>
<p className='mb-8 text-[#666] text-[15px]'>
That course or lesson doesn't exist in the Academy.
</p>
<Link
href='/academy'
className='rounded-[5px] bg-[#ECECEC] px-5 py-2.5 font-[430] text-[#1C1C1C] text-[14px] transition-colors hover:bg-white'
>
Back to Academy
</Link>
</main>
)
}

View File

@@ -0,0 +1,76 @@
import { BookOpen, Clock } from 'lucide-react'
import Link from 'next/link'
import { COURSES } from '@/lib/academy/content'
export default function AcademyCatalogPage() {
return (
<main>
<section className='border-[#2A2A2A] border-b px-4 py-20 sm:px-8 md:px-[80px]'>
<div className='mx-auto max-w-3xl'>
<div className='mb-3 text-[#999] text-[13px] uppercase tracking-[0.12em]'>
Sim Academy
</div>
<h1 className='mb-4 font-[430] text-[#ECECEC] text-[48px] leading-[110%] tracking-[-0.02em]'>
Become a certified
<br />
Sim partner
</h1>
<p className='text-[#F6F6F0]/60 text-[18px] leading-[160%] tracking-[0.01em]'>
Master AI workflow automation with hands-on interactive exercises on the real Sim
canvas. Complete the program to earn your partner certification.
</p>
</div>
</section>
<section className='px-4 py-16 sm:px-8 md:px-[80px]'>
<div className='mx-auto max-w-6xl'>
<h2 className='mb-8 text-[#999] text-[13px] uppercase tracking-[0.12em]'>Courses</h2>
<div className='grid gap-5 sm:grid-cols-2 lg:grid-cols-3'>
{COURSES.map((course) => {
const totalLessons = course.modules.reduce((n, m) => n + m.lessons.length, 0)
return (
<Link
key={course.id}
href={`/academy/${course.slug}`}
className='group flex flex-col rounded-[8px] border border-[#2A2A2A] bg-[#232323] p-5 transition-colors hover:border-[#3A3A3A] hover:bg-[#282828]'
>
{course.imageUrl && (
<div className='mb-4 aspect-video w-full overflow-hidden rounded-[6px] bg-[#1A1A1A]'>
<img
src={course.imageUrl}
alt={course.title}
className='h-full w-full object-cover opacity-80'
/>
</div>
)}
<div className='flex-1'>
<h3 className='mb-2 font-[430] text-[#ECECEC] text-[16px] leading-[130%] group-hover:text-white'>
{course.title}
</h3>
{course.description && (
<p className='mb-4 line-clamp-2 text-[#999] text-[14px] leading-[150%]'>
{course.description}
</p>
)}
</div>
<div className='mt-auto flex items-center gap-4 text-[#666] text-[12px]'>
{course.estimatedMinutes && (
<span className='flex items-center gap-1.5'>
<Clock className='h-3 w-3' />
{course.estimatedMinutes} min
</span>
)}
<span className='flex items-center gap-1.5'>
<BookOpen className='h-3 w-3' />
{totalLessons} lessons
</span>
</div>
</Link>
)
})}
</div>
</div>
</section>
</main>
)
}

View File

@@ -0,0 +1,66 @@
'use client'
import { useCallback, useState } from 'react'
import { CheckCircle2 } from 'lucide-react'
import { markLessonComplete } from '@/lib/academy/local-progress'
import type { ExerciseBlockState, ExerciseDefinition, ExerciseEdgeState } from '@/lib/academy/types'
import { SandboxCanvasProvider } from '@/app/academy/components/sandbox-canvas-provider'
interface ExerciseViewProps {
lessonId: string
exerciseConfig: ExerciseDefinition
onComplete?: () => void
videoUrl?: string
description?: string
}
/**
* Orchestrates the sandbox canvas for an exercise lesson.
* Completion is determined client-side by the validation engine and persisted to localStorage.
*/
export function ExerciseView({
lessonId,
exerciseConfig,
onComplete,
videoUrl,
description,
}: ExerciseViewProps) {
const [completed, setCompleted] = useState(false)
// Reset completion banner when the lesson changes (component is reused across exercise navigations).
const [prevLessonId, setPrevLessonId] = useState(lessonId)
if (prevLessonId !== lessonId) {
setPrevLessonId(lessonId)
setCompleted(false)
}
const handleComplete = useCallback(
(_blocks: ExerciseBlockState[], _edges: ExerciseEdgeState[]) => {
setCompleted(true)
markLessonComplete(lessonId)
onComplete?.()
},
[lessonId, onComplete]
)
return (
<div className='relative flex h-full w-full flex-col overflow-hidden'>
<SandboxCanvasProvider
exerciseId={lessonId}
exerciseConfig={exerciseConfig}
onComplete={handleComplete}
videoUrl={videoUrl}
description={description}
className='flex-1'
/>
{completed && (
<div className='pointer-events-none absolute inset-0 flex items-start justify-center pt-5'>
<div className='pointer-events-auto flex items-center gap-2 rounded-full border border-[#3A4A3A] bg-[#1F2A1F]/95 px-4 py-2 font-[430] text-[#4CAF50] text-[13px] shadow-lg backdrop-blur-sm'>
<CheckCircle2 className='h-4 w-4' />
Exercise complete!
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,256 @@
'use client'
import { useState } from 'react'
import { CheckCircle2, XCircle } from 'lucide-react'
import { markLessonComplete } from '@/lib/academy/local-progress'
import type { QuizDefinition, QuizQuestion } from '@/lib/academy/types'
import { cn } from '@/lib/core/utils/cn'
interface LessonQuizProps {
lessonId: string
quizConfig: QuizDefinition
onPass?: () => void
}
type Answers = Record<number, number | number[] | boolean>
interface QuizResult {
score: number
passed: boolean
feedback: Array<{ correct: boolean; explanation?: string }>
}
function scoreQuiz(questions: QuizQuestion[], answers: Answers, passingScore: number): QuizResult {
const feedback = questions.map((q, i) => {
const answer = answers[i]
let correct = false
if (q.type === 'multiple_choice') correct = answer === q.correctIndex
else if (q.type === 'true_false') correct = answer === q.correctAnswer
else if (q.type === 'multi_select') {
const selected = (answer as number[] | undefined) ?? []
correct =
selected.length === q.correctIndices.length &&
selected.every((v) => q.correctIndices.includes(v))
} else {
const _exhaustive: never = q
void _exhaustive
}
return { correct, explanation: 'explanation' in q ? q.explanation : undefined }
})
const score = Math.round((feedback.filter((f) => f.correct).length / questions.length) * 100)
return { score, passed: score >= passingScore, feedback }
}
const optionBase =
'w-full text-left rounded-[6px] border px-4 py-3 text-[14px] transition-colors disabled:cursor-default'
/**
* Interactive quiz component with per-question feedback and retry support.
* Scoring is performed entirely client-side.
*/
export function LessonQuiz({ lessonId, quizConfig, onPass }: LessonQuizProps) {
const [answers, setAnswers] = useState<Answers>({})
const [result, setResult] = useState<QuizResult | null>(null)
// Reset quiz state when the lesson changes (component is reused across quiz-lesson navigations).
const [prevLessonId, setPrevLessonId] = useState(lessonId)
if (prevLessonId !== lessonId) {
setPrevLessonId(lessonId)
setAnswers({})
setResult(null)
}
const handleAnswer = (qi: number, value: number | boolean) => {
if (!result) setAnswers((prev) => ({ ...prev, [qi]: value }))
}
const handleMultiSelect = (qi: number, oi: number) => {
if (result) return
setAnswers((prev) => {
const current = (prev[qi] as number[] | undefined) ?? []
const next = current.includes(oi) ? current.filter((i) => i !== oi) : [...current, oi]
return { ...prev, [qi]: next }
})
}
const allAnswered = quizConfig.questions.every((q, i) => {
if (q.type === 'multi_select')
return Array.isArray(answers[i]) && (answers[i] as number[]).length > 0
return answers[i] !== undefined
})
const handleSubmit = () => {
const scored = scoreQuiz(quizConfig.questions, answers, quizConfig.passingScore)
setResult(scored)
if (scored.passed) {
markLessonComplete(lessonId)
onPass?.()
}
}
return (
<div className='space-y-6'>
<div>
<h2 className='font-[430] text-[#ECECEC] text-[20px]'>Quiz</h2>
<p className='mt-1 text-[#666] text-[14px]'>
Score {quizConfig.passingScore}% or higher to pass.
</p>
</div>
{quizConfig.questions.map((q, qi) => {
const feedback = result?.feedback[qi]
const isCorrect = feedback?.correct
return (
<div key={qi} className='rounded-[8px] bg-[#222] p-5'>
<p className='mb-4 font-[430] text-[#ECECEC] text-[15px]'>{q.question}</p>
{q.type === 'multiple_choice' && (
<div className='space-y-2'>
{q.options.map((opt, oi) => (
<button
key={oi}
type='button'
onClick={() => handleAnswer(qi, oi)}
disabled={Boolean(result)}
className={cn(
optionBase,
answers[qi] === oi
? 'border-[#ECECEC]/40 bg-[#ECECEC]/5 text-[#ECECEC]'
: 'border-[#2A2A2A] text-[#999] hover:border-[#3A3A3A] hover:bg-[#272727]',
result &&
oi === q.correctIndex &&
'border-[#4CAF50]/50 bg-[#4CAF50]/5 text-[#4CAF50]',
result &&
answers[qi] === oi &&
oi !== q.correctIndex &&
'border-[#f44336]/40 bg-[#f44336]/5 text-[#f44336]'
)}
>
{opt}
</button>
))}
</div>
)}
{q.type === 'true_false' && (
<div className='flex gap-3'>
{(['True', 'False'] as const).map((label) => {
const val = label === 'True'
return (
<button
key={label}
type='button'
onClick={() => handleAnswer(qi, val)}
disabled={Boolean(result)}
className={cn(
'flex-1 rounded-[6px] border px-4 py-3 text-[14px] transition-colors disabled:cursor-default',
answers[qi] === val
? 'border-[#ECECEC]/40 bg-[#ECECEC]/5 text-[#ECECEC]'
: 'border-[#2A2A2A] text-[#999] hover:border-[#3A3A3A] hover:bg-[#272727]',
result &&
val === q.correctAnswer &&
'border-[#4CAF50]/50 bg-[#4CAF50]/5 text-[#4CAF50]',
result &&
answers[qi] === val &&
val !== q.correctAnswer &&
'border-[#f44336]/40 bg-[#f44336]/5 text-[#f44336]'
)}
>
{label}
</button>
)
})}
</div>
)}
{q.type === 'multi_select' && (
<div className='space-y-2'>
{q.options.map((opt, oi) => {
const selected = ((answers[qi] as number[]) ?? []).includes(oi)
return (
<button
key={oi}
type='button'
onClick={() => handleMultiSelect(qi, oi)}
disabled={Boolean(result)}
className={cn(
optionBase,
selected
? 'border-[#ECECEC]/40 bg-[#ECECEC]/5 text-[#ECECEC]'
: 'border-[#2A2A2A] text-[#999] hover:border-[#3A3A3A] hover:bg-[#272727]',
result &&
q.correctIndices.includes(oi) &&
'border-[#4CAF50]/50 bg-[#4CAF50]/5 text-[#4CAF50]',
result &&
selected &&
!q.correctIndices.includes(oi) &&
'border-[#f44336]/40 bg-[#f44336]/5 text-[#f44336]'
)}
>
{opt}
</button>
)
})}
</div>
)}
{feedback && (
<div
className={cn(
'mt-3 flex items-start gap-2 rounded-[6px] px-3 py-2.5 text-[13px]',
isCorrect ? 'bg-[#4CAF50]/10 text-[#4CAF50]' : 'bg-[#f44336]/10 text-[#f44336]'
)}
>
{isCorrect ? (
<CheckCircle2 className='mt-0.5 h-3.5 w-3.5 flex-shrink-0' />
) : (
<XCircle className='mt-0.5 h-3.5 w-3.5 flex-shrink-0' />
)}
<span>{isCorrect ? 'Correct!' : (feedback.explanation ?? 'Incorrect.')}</span>
</div>
)}
</div>
)
})}
{result && (
<div
className={cn(
'rounded-[8px] border p-5',
result.passed
? 'border-[#3A4A3A] bg-[#1F2A1F] text-[#4CAF50]'
: 'border-[#3A2A2A] bg-[#2A1F1F] text-[#f44336]'
)}
>
<p className='font-[430] text-[15px]'>{result.passed ? 'Passed!' : 'Keep trying!'}</p>
<p className='mt-1 text-[13px] opacity-80'>
Score: {result.score}% (passing: {quizConfig.passingScore}%)
</p>
{!result.passed && (
<button
type='button'
onClick={() => {
setAnswers({})
setResult(null)
}}
className='mt-3 rounded-[5px] border border-[#3A2A2A] bg-[#2A1F1F] px-3 py-1.5 text-[#999] text-[13px] transition-colors hover:border-[#4A3A3A] hover:text-[#ECECEC]'
>
Retry
</button>
)}
</div>
)}
{!result && (
<button
type='button'
onClick={handleSubmit}
disabled={!allAnswered}
className='rounded-[5px] bg-[#ECECEC] px-5 py-2.5 font-[430] text-[#1C1C1C] text-[14px] transition-colors hover:bg-white disabled:opacity-40'
>
Submit answers
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,23 @@
import type React from 'react'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
interface LessonLayoutProps {
children: React.ReactNode
params: Promise<{ courseSlug: string; lessonSlug: string }>
}
/**
* Server-side auth gate for lesson pages.
* Redirects unauthenticated users to login before any client JS runs.
*/
export default async function LessonLayout({ children, params }: LessonLayoutProps) {
const session = await getSession()
if (!session?.user?.id) {
const { courseSlug, lessonSlug } = await params
redirect(`/login?callbackUrl=/academy/${courseSlug}/${lessonSlug}`)
}
return <>{children}</>
}

View File

@@ -0,0 +1,219 @@
'use client'
import { use, useCallback, useEffect, useMemo, useState } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { getCourse } from '@/lib/academy/content'
import { markLessonComplete } from '@/lib/academy/local-progress'
import type { Lesson } from '@/lib/academy/types'
import { LessonVideo } from '@/app/academy/components/lesson-video'
import { ExerciseView } from './components/exercise-view'
import { LessonQuiz } from './components/lesson-quiz'
const navBtnClass =
'flex items-center gap-1 rounded-[5px] border border-[#2A2A2A] px-3 py-1.5 text-[#999] text-[12px] transition-colors hover:border-[#3A3A3A] hover:text-[#ECECEC]'
interface LessonPageProps {
params: Promise<{ courseSlug: string; lessonSlug: string }>
}
export default function LessonPage({ params }: LessonPageProps) {
const { courseSlug, lessonSlug } = use(params)
const course = getCourse(courseSlug)
const [exerciseComplete, setExerciseComplete] = useState(false)
const [quizComplete, setQuizComplete] = useState(false)
// Reset completion state when the lesson changes (Next.js reuses the component across navigations).
const [prevLessonSlug, setPrevLessonSlug] = useState(lessonSlug)
if (prevLessonSlug !== lessonSlug) {
setPrevLessonSlug(lessonSlug)
setExerciseComplete(false)
setQuizComplete(false)
}
const allLessons = useMemo<Lesson[]>(
() => course?.modules.flatMap((m) => m.lessons) ?? [],
[course]
)
const currentIndex = allLessons.findIndex((l) => l.slug === lessonSlug)
const lesson = allLessons[currentIndex]
const prevLesson = currentIndex > 0 ? allLessons[currentIndex - 1] : null
const nextLesson = currentIndex < allLessons.length - 1 ? allLessons[currentIndex + 1] : null
const handleExerciseComplete = useCallback(() => setExerciseComplete(true), [])
const handleQuizPass = useCallback(() => setQuizComplete(true), [])
const canAdvance =
(!lesson?.exerciseConfig && !lesson?.quizConfig) ||
(Boolean(lesson?.exerciseConfig) && Boolean(lesson?.quizConfig)
? exerciseComplete && quizComplete
: lesson?.exerciseConfig
? exerciseComplete
: quizComplete)
const isUngatedLesson =
lesson?.lessonType === 'video' ||
(lesson?.lessonType === 'mixed' && !lesson.exerciseConfig && !lesson.quizConfig)
useEffect(() => {
if (isUngatedLesson && lesson) {
markLessonComplete(lesson.id)
}
}, [lesson?.id, isUngatedLesson])
if (!course || !lesson) {
return (
<div className='flex h-screen items-center justify-center bg-[#1C1C1C]'>
<p className='text-[#666] text-[14px]'>Lesson not found.</p>
</div>
)
}
const hasVideo = Boolean(lesson.videoUrl)
const hasExercise = Boolean(lesson.exerciseConfig)
const hasQuiz = Boolean(lesson.quizConfig)
return (
<div className='fixed inset-0 flex flex-col overflow-hidden bg-[#1C1C1C]'>
<header className='flex h-[52px] flex-shrink-0 items-center justify-between border-[#2A2A2A] border-b bg-[#1C1C1C] px-5'>
<div className='flex items-center gap-3 text-[13px]'>
<Link href='/' aria-label='Sim home'>
<Image
src='/logo/b&w/text/b&w.svg'
alt='Sim'
width={40}
height={14}
className='opacity-70 invert transition-opacity hover:opacity-100'
/>
</Link>
<span className='text-[#333]'>/</span>
<Link href='/academy' className='text-[#666] transition-colors hover:text-[#999]'>
Academy
</Link>
<span className='text-[#333]'>/</span>
<Link
href={`/academy/${courseSlug}`}
className='max-w-[160px] truncate text-[#666] transition-colors hover:text-[#999]'
>
{course.title}
</Link>
<span className='text-[#333]'>/</span>
<span className='max-w-[200px] truncate text-[#ECECEC]'>{lesson.title}</span>
</div>
<div className='flex items-center gap-2'>
{prevLesson ? (
<Link href={`/academy/${courseSlug}/${prevLesson.slug}`} className={navBtnClass}>
<ChevronLeft className='h-3.5 w-3.5' />
Previous
</Link>
) : (
<Link href={`/academy/${courseSlug}`} className={navBtnClass}>
<ChevronLeft className='h-3.5 w-3.5' />
Course
</Link>
)}
{nextLesson && (
<Link
href={`/academy/${courseSlug}/${nextLesson.slug}`}
onClick={(e) => {
if (!canAdvance) e.preventDefault()
}}
className={`flex items-center gap-1 rounded-[5px] px-3 py-1.5 text-[12px] transition-colors ${
canAdvance
? 'bg-[#ECECEC] text-[#1C1C1C] hover:bg-white'
: 'cursor-not-allowed border border-[#2A2A2A] text-[#444]'
}`}
>
Next
<ChevronRight className='h-3.5 w-3.5' />
</Link>
)}
</div>
</header>
<div className='flex min-h-0 flex-1 overflow-hidden'>
{lesson.lessonType === 'video' && hasVideo && (
<div className='flex-1 overflow-y-auto p-10'>
<div className='mx-auto w-full max-w-3xl'>
<LessonVideo url={lesson.videoUrl!} title={lesson.title} />
{lesson.description && (
<p className='mt-5 text-[#999] text-[15px] leading-[160%]'>{lesson.description}</p>
)}
</div>
</div>
)}
{lesson.lessonType === 'exercise' && hasExercise && (
<ExerciseView
lessonId={lesson.id}
exerciseConfig={lesson.exerciseConfig!}
onComplete={handleExerciseComplete}
/>
)}
{lesson.lessonType === 'quiz' && hasQuiz && (
<div className='flex-1 overflow-y-auto p-10'>
<div className='mx-auto w-full max-w-2xl'>
<LessonQuiz
lessonId={lesson.id}
quizConfig={lesson.quizConfig!}
onPass={handleQuizPass}
/>
</div>
</div>
)}
{lesson.lessonType === 'mixed' && (
<>
{hasExercise && (!exerciseComplete || !hasQuiz) && (
<ExerciseView
lessonId={lesson.id}
exerciseConfig={lesson.exerciseConfig!}
onComplete={handleExerciseComplete}
videoUrl={!hasQuiz ? lesson.videoUrl : undefined}
description={!hasQuiz ? lesson.description : undefined}
/>
)}
{hasExercise && exerciseComplete && hasQuiz && (
<div className='flex-1 overflow-y-auto p-8'>
<div className='mx-auto w-full max-w-xl space-y-8'>
{hasVideo && <LessonVideo url={lesson.videoUrl!} title={lesson.title} />}
<LessonQuiz
lessonId={lesson.id}
quizConfig={lesson.quizConfig!}
onPass={handleQuizPass}
/>
</div>
</div>
)}
{!hasExercise && hasQuiz && (
<div className='flex-1 overflow-y-auto p-8'>
<div className='mx-auto w-full max-w-xl space-y-8'>
{hasVideo && <LessonVideo url={lesson.videoUrl!} title={lesson.title} />}
<LessonQuiz
lessonId={lesson.id}
quizConfig={lesson.quizConfig!}
onPass={handleQuizPass}
/>
</div>
</div>
)}
{!hasExercise && !hasQuiz && hasVideo && (
<div className='flex-1 overflow-y-auto p-10'>
<div className='mx-auto w-full max-w-3xl'>
<LessonVideo url={lesson.videoUrl!} title={lesson.title} />
{lesson.description && (
<p className='mt-5 text-[#999] text-[15px] leading-[160%]'>
{lesson.description}
</p>
)}
</div>
</div>
)}
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,56 @@
'use client'
interface LessonVideoProps {
url: string
title: string
}
export function LessonVideo({ url, title }: LessonVideoProps) {
const embedUrl = resolveEmbedUrl(url)
if (!embedUrl) {
return (
<div className='flex aspect-video items-center justify-center rounded-lg bg-[#1A1A1A] text-[#666] text-sm'>
Video unavailable
</div>
)
}
return (
<div className='aspect-video w-full overflow-hidden rounded-lg bg-black'>
<iframe
src={embedUrl}
title={title}
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture'
allowFullScreen
className='h-full w-full border-0'
/>
</div>
)
}
function resolveEmbedUrl(url: string): string | null {
try {
const parsed = new URL(url)
if (parsed.hostname === 'youtu.be') {
return `https://www.youtube.com/embed${parsed.pathname}`
}
if (parsed.hostname.includes('youtube.com')) {
// Shorts: youtube.com/shorts/VIDEO_ID
const shortsMatch = parsed.pathname.match(/^\/shorts\/([^/?]+)/)
if (shortsMatch) return `https://www.youtube.com/embed/${shortsMatch[1]}`
const v = parsed.searchParams.get('v')
if (v) return `https://www.youtube.com/embed/${v}`
}
if (parsed.hostname === 'vimeo.com') {
const id = parsed.pathname.replace(/^\//, '')
if (id) return `https://player.vimeo.com/video/${id}`
}
return null
} catch (_e: unknown) {
return null
}
}

View File

@@ -0,0 +1,443 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import type { Edge } from 'reactflow'
import { buildMockExecutionPlan } from '@/lib/academy/mock-execution'
import type {
ExerciseBlockState,
ExerciseDefinition,
ExerciseEdgeState,
ValidationResult,
} from '@/lib/academy/types'
import { validateExercise } from '@/lib/academy/validation'
import { cn } from '@/lib/core/utils/cn'
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { SandboxWorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import Workflow from '@/app/workspace/[workspaceId]/w/[workflowId]/workflow'
import { getBlock } from '@/blocks/registry'
import { workflowKeys } from '@/hooks/queries/workflows'
import { SandboxBlockConstraintsContext } from '@/hooks/use-sandbox-block-constraints'
import { useExecutionStore } from '@/stores/execution/store'
import { useTerminalConsoleStore } from '@/stores/terminal/console/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState, SubBlockState, WorkflowState } from '@/stores/workflows/workflow/types'
import { LessonVideo } from './lesson-video'
import { ValidationChecklist } from './validation-checklist'
const logger = createLogger('SandboxCanvasProvider')
const SANDBOX_WORKSPACE_ID = 'sandbox'
interface SandboxCanvasProviderProps {
/** Unique ID for this exercise instance */
exerciseId: string
/** Full exercise configuration */
exerciseConfig: ExerciseDefinition
/**
* Called when all validation rules pass for the first time.
* Receives the current canvas state so the caller can persist it.
*/
onComplete?: (blocks: ExerciseBlockState[], edges: ExerciseEdgeState[]) => void
/** Optional video URL (YouTube/Vimeo) shown above the checklist — used for mixed lessons */
videoUrl?: string
/** Optional description shown below the video (or below checklist if no video) */
description?: string
className?: string
}
/**
* Builds a Zustand-compatible WorkflowState from exercise block/edge definitions.
* Looks up each block type in the registry to construct proper sub-block and output maps.
*/
function buildWorkflowState(
initialBlocks: ExerciseBlockState[],
initialEdges: ExerciseEdgeState[]
): WorkflowState {
const blocks: Record<string, BlockState> = {}
for (const exerciseBlock of initialBlocks) {
const config = getBlock(exerciseBlock.type)
if (!config) {
logger.warn(`Unknown block type "${exerciseBlock.type}" in exercise config`)
continue
}
const subBlocks: Record<string, SubBlockState> = {}
for (const sb of config.subBlocks ?? []) {
const overrideValue = exerciseBlock.subBlocks?.[sb.id]
subBlocks[sb.id] = {
id: sb.id,
type: sb.type,
value: (overrideValue !== undefined ? overrideValue : null) as SubBlockState['value'],
}
}
const outputs = getEffectiveBlockOutputs(exerciseBlock.type, subBlocks, {
triggerMode: false,
preferToolOutputs: true,
})
blocks[exerciseBlock.id] = {
id: exerciseBlock.id,
type: exerciseBlock.type,
name: config.name,
position: exerciseBlock.position,
subBlocks,
outputs,
enabled: true,
horizontalHandles: true,
advancedMode: false,
triggerMode: false,
height: 0,
locked: exerciseBlock.locked ?? false,
}
}
const edges: Edge[] = initialEdges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle,
targetHandle: e.targetHandle,
type: 'default',
data: {},
}))
return { blocks, edges, loops: {}, parallels: {}, lastSaved: Date.now() }
}
/**
* Reads the current canvas state from the workflow store and converts it to
* the exercise block/edge format used by the validation engine.
*/
function readCurrentCanvasState(workflowId: string): {
blocks: ExerciseBlockState[]
edges: ExerciseEdgeState[]
} {
const workflowStore = useWorkflowStore.getState()
const subBlockStore = useSubBlockStore.getState()
const blocks: ExerciseBlockState[] = Object.values(workflowStore.blocks).map((block) => {
const storedValues = subBlockStore.workflowValues[workflowId] ?? {}
const blockValues = storedValues[block.id] ?? {}
const subBlocks: Record<string, unknown> = { ...blockValues }
return {
id: block.id,
type: block.type,
position: block.position,
subBlocks,
}
})
const edges: ExerciseEdgeState[] = workflowStore.edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle ?? undefined,
targetHandle: e.targetHandle ?? undefined,
}))
return { blocks, edges }
}
/**
* Wraps the real Sim canvas in sandbox mode for Sim Academy exercises.
*
* - Pre-hydrates workflow stores directly (no API calls)
* - Provides sandbox permissions (canEdit: true, no workspace dependency)
* - Displays a constrained block toolbar and live validation checklist
* - Supports mock execution to simulate workflow runs
*/
export function SandboxCanvasProvider({
exerciseId,
exerciseConfig,
onComplete,
videoUrl,
description,
className,
}: SandboxCanvasProviderProps) {
const [isReady, setIsReady] = useState(false)
const [validationResult, setValidationResult] = useState<ValidationResult>({
passed: false,
results: [],
})
const [hintIndex, setHintIndex] = useState(-1)
const completedRef = useRef(false)
const onCompleteRef = useRef(onComplete)
onCompleteRef.current = onComplete
const isMockRunningRef = useRef(false)
const handleMockRunRef = useRef<() => Promise<void>>(async () => {})
// Stable exercise ID — used as the workflow ID in the stores
const workflowId = `sandbox-${exerciseId}`
const runValidation = useCallback(() => {
const { blocks, edges } = readCurrentCanvasState(workflowId)
const result = validateExercise(blocks, edges, exerciseConfig.validationRules)
setValidationResult((prev) => {
if (
prev.passed === result.passed &&
prev.results.length === result.results.length &&
prev.results.every((r, i) => r.passed === result.results[i].passed)
) {
return prev
}
return result
})
if (result.passed && !completedRef.current) {
completedRef.current = true
onCompleteRef.current?.(blocks, edges)
}
}, [workflowId, exerciseConfig.validationRules])
useEffect(() => {
completedRef.current = false
setHintIndex(-1)
const workflowState = buildWorkflowState(
exerciseConfig.initialBlocks ?? [],
exerciseConfig.initialEdges ?? []
)
const syntheticMetadata: WorkflowMetadata = {
id: workflowId,
name: 'Exercise',
lastModified: new Date(),
createdAt: new Date(),
color: '#3972F6',
workspaceId: SANDBOX_WORKSPACE_ID,
sortOrder: 0,
isSandbox: true,
}
useWorkflowStore.getState().replaceWorkflowState(workflowState)
useSubBlockStore.getState().initializeFromWorkflow(workflowId, workflowState.blocks)
const qc = getQueryClient()
const cacheKey = workflowKeys.list(SANDBOX_WORKSPACE_ID, 'active')
const cached = qc.getQueryData<WorkflowMetadata[]>(cacheKey) ?? []
qc.setQueryData(cacheKey, [...cached.filter((w) => w.id !== workflowId), syntheticMetadata])
useWorkflowRegistry.setState({
activeWorkflowId: workflowId,
hydration: {
phase: 'ready',
workspaceId: SANDBOX_WORKSPACE_ID,
workflowId,
requestId: null,
error: null,
},
})
logger.info('Sandbox stores hydrated', { workflowId })
setIsReady(true)
// Coalesce rapid store updates so validation runs at most once per animation frame.
let rafId: number | null = null
const scheduleValidation = () => {
if (rafId !== null) return
rafId = requestAnimationFrame(() => {
rafId = null
runValidation()
})
}
const unsubWorkflow = useWorkflowStore.subscribe(scheduleValidation)
const unsubSubBlock = useSubBlockStore.subscribe(scheduleValidation)
// When the panel's Run button is clicked, useWorkflowExecution sets isExecuting=true
// and returns immediately (no API call). Detect that signal here and run mock execution.
const unsubExecution = useExecutionStore.subscribe((state) => {
const isExec = state.workflowExecutions.get(workflowId)?.isExecuting
if (isExec && !isMockRunningRef.current) {
void handleMockRunRef.current()
}
})
runValidation()
return () => {
if (rafId !== null) cancelAnimationFrame(rafId)
unsubWorkflow()
unsubSubBlock()
unsubExecution()
const cleanupQc = getQueryClient()
const cleanupKey = workflowKeys.list(SANDBOX_WORKSPACE_ID, 'active')
const cleanupCached = cleanupQc.getQueryData<WorkflowMetadata[]>(cleanupKey) ?? []
cleanupQc.setQueryData(
cleanupKey,
cleanupCached.filter((w) => w.id !== workflowId)
)
useWorkflowRegistry.setState((state) => ({
activeWorkflowId: state.activeWorkflowId === workflowId ? null : state.activeWorkflowId,
hydration:
state.hydration.workflowId === workflowId
? { phase: 'idle', workspaceId: null, workflowId: null, requestId: null, error: null }
: state.hydration,
}))
useWorkflowStore.setState({ blocks: {}, edges: [], loops: {}, parallels: {} })
useSubBlockStore.setState((state) => {
const { [workflowId]: _removed, ...rest } = state.workflowValues
return { workflowValues: rest }
})
}
}, [workflowId, exerciseConfig.initialBlocks, exerciseConfig.initialEdges, runValidation])
const handleMockRun = useCallback(async () => {
if (isMockRunningRef.current) return
isMockRunningRef.current = true
const { setActiveBlocks, setIsExecuting } = useExecutionStore.getState()
const { blocks, edges } = readCurrentCanvasState(workflowId)
const result = validateExercise(blocks, edges, exerciseConfig.validationRules)
setValidationResult(result)
if (!result.passed) {
isMockRunningRef.current = false
setIsExecuting(workflowId, false)
return
}
const plan = buildMockExecutionPlan(blocks, edges, exerciseConfig.mockOutputs ?? {})
if (plan.length === 0) {
isMockRunningRef.current = false
setIsExecuting(workflowId, false)
return
}
const { addConsole, clearWorkflowConsole } = useTerminalConsoleStore.getState()
const workflowBlocks = useWorkflowStore.getState().blocks
setIsExecuting(workflowId, true)
clearWorkflowConsole(workflowId)
useTerminalConsoleStore.setState({ isOpen: true })
try {
for (let i = 0; i < plan.length; i++) {
const step = plan[i]
setActiveBlocks(workflowId, new Set([step.blockId]))
await new Promise((resolve) => setTimeout(resolve, step.delay))
addConsole({
workflowId,
blockId: step.blockId,
blockName: workflowBlocks[step.blockId]?.name ?? step.blockType,
blockType: step.blockType,
executionOrder: i,
output: step.output,
success: true,
durationMs: step.delay,
})
setActiveBlocks(workflowId, new Set())
}
} finally {
setIsExecuting(workflowId, false)
isMockRunningRef.current = false
}
}, [workflowId, exerciseConfig.validationRules, exerciseConfig.mockOutputs])
handleMockRunRef.current = handleMockRun
const handleShowHint = useCallback(() => {
const hints = exerciseConfig.hints ?? []
if (hints.length === 0) return
setHintIndex((i) => Math.min(i + 1, hints.length - 1))
}, [exerciseConfig.hints])
const handlePrevHint = useCallback(() => {
setHintIndex((i) => Math.max(i - 1, 0))
}, [])
if (!isReady) {
return (
<div className='flex h-full w-full items-center justify-center bg-[#0e0e0e]'>
<div className='h-5 w-5 animate-spin rounded-full border-2 border-[#ECECEC] border-t-transparent' />
</div>
)
}
const hints = exerciseConfig.hints ?? []
const currentHint = hintIndex >= 0 ? hints[hintIndex] : null
return (
<SandboxBlockConstraintsContext.Provider value={exerciseConfig.availableBlocks}>
<GlobalCommandsProvider>
<SandboxWorkspacePermissionsProvider>
<div className={cn('flex h-full w-full overflow-hidden', className)}>
<div className='flex w-56 flex-shrink-0 flex-col gap-3 overflow-y-auto border-[#1F1F1F] border-r bg-[#141414] p-3'>
{(videoUrl || description) && (
<div className='flex flex-col gap-2'>
{videoUrl && <LessonVideo url={videoUrl} title='Lesson video' />}
{description && (
<p className='text-[#666] text-[11px] leading-relaxed'>{description}</p>
)}
<div className='border-[#1F1F1F] border-t' />
</div>
)}
{exerciseConfig.instructions && (
<p className='text-[#999] text-[11px] leading-relaxed'>
{exerciseConfig.instructions}
</p>
)}
<ValidationChecklist
results={validationResult.results}
allPassed={validationResult.passed}
/>
<div className='mt-auto flex flex-col gap-2'>
{currentHint && (
<div className='rounded-[6px] border border-[#2A2A2A] bg-[#1A1A1A] px-3 py-2 text-[11px]'>
<div className='mb-1 flex items-center justify-between'>
<span className='font-[430] text-[#666]'>
Hint {hintIndex + 1}/{hints.length}
</span>
<div className='flex gap-1'>
<button
type='button'
onClick={handlePrevHint}
disabled={hintIndex === 0}
className='rounded px-1 text-[#666] transition-colors hover:text-[#ECECEC] disabled:opacity-30'
aria-label='Previous hint'
>
</button>
<button
type='button'
onClick={handleShowHint}
disabled={hintIndex === hints.length - 1}
className='rounded px-1 text-[#666] transition-colors hover:text-[#ECECEC] disabled:opacity-30'
aria-label='Next hint'
>
</button>
</div>
</div>
<span className='text-[#ECECEC]'>{currentHint}</span>
</div>
)}
{hints.length > 0 && hintIndex < 0 && (
<button
type='button'
onClick={handleShowHint}
className='w-full rounded-[5px] border border-[#2A2A2A] bg-[#1A1A1A] px-3 py-1.5 text-[#999] text-[12px] transition-colors hover:border-[#3A3A3A] hover:text-[#ECECEC]'
>
Show hint
</button>
)}
</div>
</div>
<div className='relative flex-1 overflow-hidden'>
<Workflow workspaceId={SANDBOX_WORKSPACE_ID} workflowId={workflowId} sandbox />
</div>
</div>
</SandboxWorkspacePermissionsProvider>
</GlobalCommandsProvider>
</SandboxBlockConstraintsContext.Provider>
)
}

View File

@@ -0,0 +1,50 @@
'use client'
import { CheckCircle2, Circle } from 'lucide-react'
import type { ValidationRuleResult } from '@/lib/academy/types'
import { cn } from '@/lib/core/utils/cn'
interface ValidationChecklistProps {
results: ValidationRuleResult[]
allPassed: boolean
}
/**
* Checklist showing exercise validation rules and their current pass/fail state.
* Rendered inside the exercise sidebar, not as a canvas overlay.
*/
export function ValidationChecklist({ results, allPassed }: ValidationChecklistProps) {
if (results.length === 0) return null
return (
<div>
<div className='mb-2.5 flex items-center gap-1.5'>
<span className='font-[430] text-[#ECECEC] text-[12px]'>Checklist</span>
{allPassed && (
<span className='ml-auto rounded-full bg-[#4CAF50]/15 px-2 py-0.5 font-[430] text-[#4CAF50] text-[10px]'>
Complete
</span>
)}
</div>
<ul className='space-y-1.5'>
{results.map((result, i) => (
<li key={i} className='flex items-start gap-2'>
{result.passed ? (
<CheckCircle2 className='mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-[#4CAF50]' />
) : (
<Circle className='mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-[#444]' />
)}
<span
className={cn(
'text-[11px] leading-tight',
result.passed ? 'text-[#555] line-through' : 'text-[#ECECEC]'
)}
>
{result.message}
</span>
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,33 @@
import type React from 'react'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
// TODO: Remove notFound() call to make academy pages public once content is ready
const ACADEMY_ENABLED = false
export const metadata: Metadata = {
title: {
absolute: 'Sim Academy',
template: '%s | Sim Academy',
},
description:
'Become a certified Sim partner — learn to build, integrate, and deploy AI workflows.',
metadataBase: new URL('https://sim.ai'),
openGraph: {
title: 'Sim Academy',
description: 'Become a certified Sim partner.',
type: 'website',
},
}
export default function AcademyLayout({ children }: { children: React.ReactNode }) {
if (!ACADEMY_ENABLED) {
notFound()
}
return (
<div className='min-h-screen bg-[#1C1C1C] font-[430] font-season text-[#ECECEC]'>
{children}
</div>
)
}

View File

@@ -0,0 +1,215 @@
import { db } from '@sim/db'
import { academyCertificate, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getCourseById } from '@/lib/academy/content'
import type { CertificateMetadata } from '@/lib/academy/types'
import { getSession } from '@/lib/auth'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
import { RateLimiter } from '@/lib/core/rate-limiter'
const logger = createLogger('AcademyCertificatesAPI')
const rateLimiter = new RateLimiter()
const CERT_RATE_LIMIT: TokenBucketConfig = {
maxTokens: 5,
refillRate: 1,
refillIntervalMs: 60 * 60_000, // 1 per hour refill
}
const IssueCertificateSchema = z.object({
courseId: z.string(),
completedLessonIds: z.array(z.string()),
})
/**
* POST /api/academy/certificates
* Issues a certificate for the given course after verifying all lessons are completed.
* Completion is client-attested: the client sends completed lesson IDs and the server
* validates them against the full lesson list for the course.
*/
export async function POST(req: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { allowed } = await rateLimiter.checkRateLimitDirect(
`academy:cert:${session.user.id}`,
CERT_RATE_LIMIT
)
if (!allowed) {
return NextResponse.json({ error: 'Too many requests' }, { status: 429 })
}
const body = await req.json()
const parsed = IssueCertificateSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
}
const { courseId, completedLessonIds } = parsed.data
const course = getCourseById(courseId)
if (!course) {
return NextResponse.json({ error: 'Course not found' }, { status: 404 })
}
// Verify all lessons in the course are reported as completed
const allLessonIds = course.modules.flatMap((m) => m.lessons.map((l) => l.id))
const completedSet = new Set(completedLessonIds)
const incomplete = allLessonIds.filter((id) => !completedSet.has(id))
if (incomplete.length > 0) {
return NextResponse.json({ error: 'Course not fully completed', incomplete }, { status: 422 })
}
const [existing, learner] = await Promise.all([
db
.select()
.from(academyCertificate)
.where(
and(
eq(academyCertificate.userId, session.user.id),
eq(academyCertificate.courseId, courseId)
)
)
.limit(1)
.then((rows) => rows[0] ?? null),
db
.select({ name: user.name })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
.then((rows) => rows[0] ?? null),
])
if (existing) {
if (existing.status === 'active') {
return NextResponse.json({ certificate: existing })
}
return NextResponse.json(
{ error: 'A certificate for this course already exists but is not active.' },
{ status: 409 }
)
}
const certificateNumber = generateCertificateNumber()
const metadata: CertificateMetadata = {
recipientName: learner?.name ?? session.user.name ?? 'Partner',
courseTitle: course.title,
}
const [certificate] = await db
.insert(academyCertificate)
.values({
id: nanoid(),
userId: session.user.id,
courseId,
status: 'active',
certificateNumber,
metadata,
})
.onConflictDoNothing()
.returning()
if (!certificate) {
const [race] = await db
.select()
.from(academyCertificate)
.where(
and(
eq(academyCertificate.userId, session.user.id),
eq(academyCertificate.courseId, courseId)
)
)
.limit(1)
if (race?.status === 'active') {
return NextResponse.json({ certificate: race })
}
if (race) {
return NextResponse.json(
{ error: 'A certificate for this course already exists but is not active.' },
{ status: 409 }
)
}
return NextResponse.json({ error: 'Failed to issue certificate' }, { status: 500 })
}
logger.info('Certificate issued', {
userId: session.user.id,
courseId,
certificateNumber,
})
return NextResponse.json({ certificate }, { status: 201 })
} catch (error) {
logger.error('Failed to issue certificate', { error })
return NextResponse.json({ error: 'Failed to issue certificate' }, { status: 500 })
}
}
/**
* GET /api/academy/certificates?certificateNumber=SIM-2026-00042
* Public endpoint for verifying a certificate by its number.
*
* GET /api/academy/certificates?courseId=...
* Authenticated endpoint for looking up the current user's certificate for a course.
*/
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const certificateNumber = searchParams.get('certificateNumber')
const courseId = searchParams.get('courseId')
if (certificateNumber) {
const [certificate] = await db
.select()
.from(academyCertificate)
.where(eq(academyCertificate.certificateNumber, certificateNumber))
.limit(1)
if (!certificate) {
return NextResponse.json({ error: 'Certificate not found' }, { status: 404 })
}
return NextResponse.json({ certificate })
}
if (courseId) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const [certificate] = await db
.select()
.from(academyCertificate)
.where(
and(
eq(academyCertificate.userId, session.user.id),
eq(academyCertificate.courseId, courseId)
)
)
.limit(1)
return NextResponse.json({ certificate: certificate ?? null })
}
return NextResponse.json(
{ error: 'certificateNumber or courseId query parameter is required' },
{ status: 400 }
)
} catch (error) {
logger.error('Failed to verify certificate', { error })
return NextResponse.json({ error: 'Failed to verify certificate' }, { status: 500 })
}
}
/** Generates a human-readable certificate number, e.g. SIM-2026-A3K9XZ2P */
function generateCertificateNumber(): string {
const year = new Date().getFullYear()
return `SIM-${year}-${nanoid(8).toUpperCase()}`
}

View File

@@ -7,7 +7,10 @@ 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 {
getCanonicalScopesForProvider,
getServiceAccountProviderForProviderId,
} from '@/lib/oauth/utils'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
@@ -36,7 +39,8 @@ function toCredentialResponse(
displayName: string,
providerId: string,
updatedAt: Date,
scope: string | null
scope: string | null,
credentialType: 'oauth' | 'service_account' = 'oauth'
) {
const storedScope = scope?.trim()
// Some providers (e.g. Box) don't return scopes in their token response,
@@ -52,6 +56,7 @@ function toCredentialResponse(
id,
name: displayName,
provider: providerId,
type: credentialType,
lastUsed: updatedAt.toISOString(),
isDefault: featureType === 'default',
scopes,
@@ -149,6 +154,7 @@ export async function GET(request: NextRequest) {
displayName: credential.displayName,
providerId: credential.providerId,
accountId: credential.accountId,
updatedAt: credential.updatedAt,
accountProviderId: account.providerId,
accountScope: account.scope,
accountUpdatedAt: account.updatedAt,
@@ -159,6 +165,49 @@ export async function GET(request: NextRequest) {
.limit(1)
if (platformCredential) {
if (platformCredential.type === 'service_account') {
if (
workflowId &&
(!effectiveWorkspaceId || platformCredential.workspaceId !== effectiveWorkspaceId)
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
if (!workflowId) {
const [membership] = await db
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, platformCredential.id),
eq(credentialMember.userId, requesterUserId),
eq(credentialMember.status, 'active')
)
)
.limit(1)
if (!membership) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
return NextResponse.json(
{
credentials: [
toCredentialResponse(
platformCredential.id,
platformCredential.displayName,
platformCredential.providerId || 'google-service-account',
platformCredential.updatedAt,
null,
'service_account'
),
],
},
{ status: 200 }
)
}
if (platformCredential.type !== 'oauth' || !platformCredential.accountId) {
return NextResponse.json({ credentials: [] }, { status: 200 })
}
@@ -238,14 +287,52 @@ export async function GET(request: NextRequest) {
)
)
return NextResponse.json(
{
credentials: credentialsData.map((row) =>
toCredentialResponse(row.id, row.displayName, row.providerId, row.updatedAt, row.scope)
),
},
{ status: 200 }
const results = credentialsData.map((row) =>
toCredentialResponse(row.id, row.displayName, row.providerId, row.updatedAt, row.scope)
)
const saProviderId = getServiceAccountProviderForProviderId(providerParam)
if (saProviderId) {
const serviceAccountCreds = await db
.select({
id: credential.id,
displayName: credential.displayName,
providerId: credential.providerId,
updatedAt: credential.updatedAt,
})
.from(credential)
.innerJoin(
credentialMember,
and(
eq(credentialMember.credentialId, credential.id),
eq(credentialMember.userId, requesterUserId),
eq(credentialMember.status, 'active')
)
)
.where(
and(
eq(credential.workspaceId, effectiveWorkspaceId),
eq(credential.type, 'service_account'),
eq(credential.providerId, saProviderId)
)
)
for (const sa of serviceAccountCreds) {
results.push(
toCredentialResponse(
sa.id,
sa.displayName,
sa.providerId || saProviderId,
sa.updatedAt,
null,
'service_account'
)
)
}
}
return NextResponse.json({ credentials: results }, { status: 200 })
}
return NextResponse.json({ credentials: [] }, { status: 200 })

View File

@@ -11,6 +11,8 @@ const {
mockGetCredential,
mockRefreshTokenIfNeeded,
mockGetOAuthToken,
mockResolveOAuthAccountId,
mockGetServiceAccountToken,
mockAuthorizeCredentialUse,
mockCheckSessionOrInternalAuth,
mockLogger,
@@ -29,6 +31,8 @@ const {
mockGetCredential: vi.fn(),
mockRefreshTokenIfNeeded: vi.fn(),
mockGetOAuthToken: vi.fn(),
mockResolveOAuthAccountId: vi.fn(),
mockGetServiceAccountToken: vi.fn(),
mockAuthorizeCredentialUse: vi.fn(),
mockCheckSessionOrInternalAuth: vi.fn(),
mockLogger: logger,
@@ -40,6 +44,8 @@ vi.mock('@/app/api/auth/oauth/utils', () => ({
getCredential: mockGetCredential,
refreshTokenIfNeeded: mockRefreshTokenIfNeeded,
getOAuthToken: mockGetOAuthToken,
resolveOAuthAccountId: mockResolveOAuthAccountId,
getServiceAccountToken: mockGetServiceAccountToken,
}))
vi.mock('@sim/logger', () => ({
@@ -50,6 +56,10 @@ vi.mock('@/lib/auth/credential-access', () => ({
authorizeCredentialUse: mockAuthorizeCredentialUse,
}))
vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn().mockReturnValue('test-request-id'),
}))
vi.mock('@/lib/auth/hybrid', () => ({
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
checkHybridAuth: vi.fn(),
@@ -62,6 +72,7 @@ import { GET, POST } from '@/app/api/auth/oauth/token/route'
describe('OAuth Token API Routes', () => {
beforeEach(() => {
vi.clearAllMocks()
mockResolveOAuthAccountId.mockResolvedValue(null)
})
/**

View File

@@ -4,7 +4,13 @@ import { z } from 'zod'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { getCredential, getOAuthToken, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import {
getCredential,
getOAuthToken,
getServiceAccountToken,
refreshTokenIfNeeded,
resolveOAuthAccountId,
} from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -18,6 +24,8 @@ const tokenRequestSchema = z
credentialAccountUserId: z.string().min(1).optional(),
providerId: z.string().min(1).optional(),
workflowId: z.string().min(1).nullish(),
scopes: z.array(z.string()).optional(),
impersonateEmail: z.string().email().optional(),
})
.refine(
(data) => data.credentialId || (data.credentialAccountUserId && data.providerId),
@@ -63,7 +71,14 @@ export async function POST(request: NextRequest) {
)
}
const { credentialId, credentialAccountUserId, providerId, workflowId } = parseResult.data
const {
credentialId,
credentialAccountUserId,
providerId,
workflowId,
scopes,
impersonateEmail,
} = parseResult.data
if (credentialAccountUserId && providerId) {
logger.info(`[${requestId}] Fetching token by credentialAccountUserId + providerId`, {
@@ -112,6 +127,31 @@ export async function POST(request: NextRequest) {
const callerUserId = new URL(request.url).searchParams.get('userId') || undefined
const resolved = await resolveOAuthAccountId(credentialId)
if (resolved?.credentialType === 'service_account' && resolved.credentialId) {
const authz = await authorizeCredentialUse(request, {
credentialId,
workflowId: workflowId ?? undefined,
requireWorkflowIdForInternal: false,
callerUserId,
})
if (!authz.ok) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
try {
const accessToken = await getServiceAccountToken(
resolved.credentialId,
scopes ?? [],
impersonateEmail
)
return NextResponse.json({ accessToken }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Service account token error:`, error)
return NextResponse.json({ error: 'Failed to get service account token' }, { status: 401 })
}
}
const authz = await authorizeCredentialUse(request, {
credentialId,
workflowId: workflowId ?? undefined,

View File

@@ -160,7 +160,12 @@ describe('OAuth Utils', () => {
describe('refreshAccessTokenIfNeeded', () => {
it('should return valid access token without refresh if not expired', async () => {
const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
const mockResolvedCredential = {
id: 'credential-id',
type: 'oauth',
accountId: 'account-id',
workspaceId: 'workspace-id',
}
const mockAccountRow = {
id: 'account-id',
accessToken: 'valid-token',
@@ -169,7 +174,7 @@ describe('OAuth Utils', () => {
providerId: 'google',
userId: 'test-user-id',
}
mockSelectChain([mockCredentialRow])
mockSelectChain([mockResolvedCredential])
mockSelectChain([mockAccountRow])
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
@@ -179,7 +184,12 @@ describe('OAuth Utils', () => {
})
it('should refresh token when expired', async () => {
const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
const mockResolvedCredential = {
id: 'credential-id',
type: 'oauth',
accountId: 'account-id',
workspaceId: 'workspace-id',
}
const mockAccountRow = {
id: 'account-id',
accessToken: 'expired-token',
@@ -188,7 +198,7 @@ describe('OAuth Utils', () => {
providerId: 'google',
userId: 'test-user-id',
}
mockSelectChain([mockCredentialRow])
mockSelectChain([mockResolvedCredential])
mockSelectChain([mockAccountRow])
mockUpdateChain()
@@ -215,7 +225,12 @@ describe('OAuth Utils', () => {
})
it('should return null if refresh fails', async () => {
const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
const mockResolvedCredential = {
id: 'credential-id',
type: 'oauth',
accountId: 'account-id',
workspaceId: 'workspace-id',
}
const mockAccountRow = {
id: 'account-id',
accessToken: 'expired-token',
@@ -224,7 +239,7 @@ describe('OAuth Utils', () => {
providerId: 'google',
userId: 'test-user-id',
}
mockSelectChain([mockCredentialRow])
mockSelectChain([mockResolvedCredential])
mockSelectChain([mockAccountRow])
mockRefreshOAuthToken.mockResolvedValueOnce(null)

View File

@@ -1,7 +1,9 @@
import { createSign } from 'crypto'
import { db } from '@sim/db'
import { account, credential, credentialSetMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq, inArray } from 'drizzle-orm'
import { decryptSecret } from '@/lib/core/security/encryption'
import { refreshOAuthToken } from '@/lib/oauth'
import {
getMicrosoftRefreshTokenExpiry,
@@ -11,6 +13,16 @@ import {
const logger = createLogger('OAuthUtilsAPI')
export class ServiceAccountTokenError extends Error {
constructor(
public readonly statusCode: number,
public readonly errorDescription: string
) {
super(errorDescription)
this.name = 'ServiceAccountTokenError'
}
}
interface AccountInsertData {
id: string
userId: string
@@ -25,16 +37,26 @@ interface AccountInsertData {
accessTokenExpiresAt?: Date
}
export interface ResolvedCredential {
accountId: string
workspaceId?: string
usedCredentialTable: boolean
credentialType?: string
credentialId?: string
}
/**
* Resolves a credential ID to its underlying account ID.
* If `credentialId` matches a `credential` row, returns its `accountId` and `workspaceId`.
* For service_account credentials, returns credentialId and type instead of accountId.
* Otherwise assumes `credentialId` is already a raw `account.id` (legacy).
*/
export async function resolveOAuthAccountId(
credentialId: string
): Promise<{ accountId: string; workspaceId?: string; usedCredentialTable: boolean } | null> {
): Promise<ResolvedCredential | null> {
const [credentialRow] = await db
.select({
id: credential.id,
type: credential.type,
accountId: credential.accountId,
workspaceId: credential.workspaceId,
@@ -44,6 +66,16 @@ export async function resolveOAuthAccountId(
.limit(1)
if (credentialRow) {
if (credentialRow.type === 'service_account') {
return {
accountId: '',
credentialId: credentialRow.id,
credentialType: 'service_account',
workspaceId: credentialRow.workspaceId,
usedCredentialTable: true,
}
}
if (credentialRow.type !== 'oauth' || !credentialRow.accountId) {
return null
}
@@ -57,6 +89,124 @@ export async function resolveOAuthAccountId(
return { accountId: credentialId, usedCredentialTable: false }
}
/**
* Userinfo scopes are excluded because service accounts don't represent a user
* and cannot request user identity information. Google rejects token requests
* that include these scopes for service account credentials.
*/
const SA_EXCLUDED_SCOPES = new Set([
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
])
/**
* Generates a short-lived access token for a Google service account credential
* using the two-legged OAuth JWT flow (RFC 7523).
*
* @param impersonateEmail - Optional. Required for Google Workspace APIs (Gmail, Drive, Calendar, etc.)
* where the service account must impersonate a domain user via domain-wide delegation.
* Not needed for project-scoped APIs like BigQuery or Vertex AI where the service account
* authenticates directly with its own IAM permissions.
*/
export async function getServiceAccountToken(
credentialId: string,
scopes: string[],
impersonateEmail?: string
): Promise<string> {
const [credentialRow] = await db
.select({
encryptedServiceAccountKey: credential.encryptedServiceAccountKey,
})
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (!credentialRow?.encryptedServiceAccountKey) {
throw new Error('Service account key not found')
}
const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey)
const keyData = JSON.parse(decrypted) as {
client_email: string
private_key: string
token_uri?: string
}
const filteredScopes = scopes.filter((s) => !SA_EXCLUDED_SCOPES.has(s))
const now = Math.floor(Date.now() / 1000)
const ALLOWED_TOKEN_URIS = new Set(['https://oauth2.googleapis.com/token'])
const tokenUri =
keyData.token_uri && ALLOWED_TOKEN_URIS.has(keyData.token_uri)
? keyData.token_uri
: 'https://oauth2.googleapis.com/token'
const header = { alg: 'RS256', typ: 'JWT' }
const payload: Record<string, unknown> = {
iss: keyData.client_email,
scope: filteredScopes.join(' '),
aud: tokenUri,
iat: now,
exp: now + 3600,
}
if (impersonateEmail) {
payload.sub = impersonateEmail
}
logger.info('Service account JWT payload', {
iss: keyData.client_email,
sub: impersonateEmail || '(none)',
scopes: filteredScopes.join(' '),
aud: tokenUri,
})
const toBase64Url = (obj: unknown) => Buffer.from(JSON.stringify(obj)).toString('base64url')
const signingInput = `${toBase64Url(header)}.${toBase64Url(payload)}`
const signer = createSign('RSA-SHA256')
signer.update(signingInput)
const signature = signer.sign(keyData.private_key, 'base64url')
const jwt = `${signingInput}.${signature}`
const response = await fetch(tokenUri, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: jwt,
}),
})
if (!response.ok) {
const errorBody = await response.text()
logger.error('Service account token exchange failed', {
status: response.status,
body: errorBody,
})
let description = `Token exchange failed: ${response.status}`
try {
const parsed = JSON.parse(errorBody) as { error_description?: string }
if (parsed.error_description) {
const raw = parsed.error_description
if (raw.includes('SignatureException') || raw.includes('Invalid signature')) {
description = 'Invalid account credentials.'
} else {
description = raw
}
}
} catch {
// use default description
}
throw new ServiceAccountTokenError(response.status, description)
}
const tokenData = (await response.json()) as { access_token: string }
return tokenData.access_token
}
/**
* Safely inserts an account record, handling duplicate constraint violations gracefully.
* If a duplicate is detected (unique constraint violation), logs a warning and returns success.
@@ -81,19 +231,13 @@ export async function safeAccountInsert(
}
/**
* Get a credential by ID and verify it belongs to the user
* Get a credential by resolved account ID and verify it belongs to the user.
*/
export async function getCredential(requestId: string, credentialId: string, userId: string) {
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
logger.warn(`[${requestId}] Credential is not an OAuth credential`)
return undefined
}
async function getCredentialByAccountId(requestId: string, accountId: string, userId: string) {
const credentials = await db
.select()
.from(account)
.where(and(eq(account.id, resolved.accountId), eq(account.userId, userId)))
.where(and(eq(account.id, accountId), eq(account.userId, userId)))
.limit(1)
if (!credentials.length) {
@@ -103,10 +247,22 @@ export async function getCredential(requestId: string, credentialId: string, use
return {
...credentials[0],
resolvedCredentialId: resolved.accountId,
resolvedCredentialId: accountId,
}
}
/**
* Get a credential by ID and verify it belongs to the user.
*/
export async function getCredential(requestId: string, credentialId: string, userId: string) {
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
logger.warn(`[${requestId}] Credential is not an OAuth credential`)
return undefined
}
return getCredentialByAccountId(requestId, resolved.accountId, userId)
}
export async function getOAuthToken(userId: string, providerId: string): Promise<string | null> {
const connections = await db
.select({
@@ -196,19 +352,36 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
}
/**
* Refreshes an OAuth token if needed based on credential information
* Refreshes an OAuth token if needed based on credential information.
* Also handles service account credentials by generating a JWT-based token.
* @param credentialId The ID of the credential to check and potentially refresh
* @param userId The user ID who owns the credential (for security verification)
* @param requestId Request ID for log correlation
* @param scopes Optional scopes for service account token generation
* @returns The valid access token or null if refresh fails
*/
export async function refreshAccessTokenIfNeeded(
credentialId: string,
userId: string,
requestId: string
requestId: string,
scopes?: string[],
impersonateEmail?: string
): Promise<string | null> {
// Get the credential directly using the getCredential helper
const credential = await getCredential(requestId, credentialId, userId)
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
return null
}
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
if (!scopes?.length) {
throw new Error('Scopes are required for service account credentials')
}
logger.info(`[${requestId}] Using service account token for credential`)
return getServiceAccountToken(resolved.credentialId, scopes, impersonateEmail)
}
// Use the already-resolved account ID to avoid a redundant resolveOAuthAccountId query
const credential = await getCredentialByAccountId(requestId, resolved.accountId, userId)
if (!credential) {
return null

View File

@@ -15,12 +15,12 @@ const {
mockLimit,
mockUpdate,
mockSet,
mockDelete,
mockCreateSuccessResponse,
mockCreateErrorResponse,
mockEncryptSecret,
mockCheckChatAccess,
mockDeployWorkflow,
mockPerformChatUndeploy,
mockLogger,
} = vi.hoisted(() => {
const logger = {
@@ -40,12 +40,12 @@ const {
mockLimit: vi.fn(),
mockUpdate: vi.fn(),
mockSet: vi.fn(),
mockDelete: vi.fn(),
mockCreateSuccessResponse: vi.fn(),
mockCreateErrorResponse: vi.fn(),
mockEncryptSecret: vi.fn(),
mockCheckChatAccess: vi.fn(),
mockDeployWorkflow: vi.fn(),
mockPerformChatUndeploy: vi.fn(),
mockLogger: logger,
}
})
@@ -66,7 +66,6 @@ vi.mock('@sim/db', () => ({
db: {
select: mockSelect,
update: mockUpdate,
delete: mockDelete,
},
}))
vi.mock('@sim/db/schema', () => ({
@@ -88,6 +87,9 @@ vi.mock('@/app/api/chat/utils', () => ({
vi.mock('@/lib/workflows/persistence/utils', () => ({
deployWorkflow: mockDeployWorkflow,
}))
vi.mock('@/lib/workflows/orchestration', () => ({
performChatUndeploy: mockPerformChatUndeploy,
}))
vi.mock('drizzle-orm', () => ({
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
@@ -106,7 +108,7 @@ describe('Chat Edit API Route', () => {
mockWhere.mockReturnValue({ limit: mockLimit })
mockUpdate.mockReturnValue({ set: mockSet })
mockSet.mockReturnValue({ where: mockWhere })
mockDelete.mockReturnValue({ where: mockWhere })
mockPerformChatUndeploy.mockResolvedValue({ success: true })
mockCreateSuccessResponse.mockImplementation((data) => {
return new Response(JSON.stringify(data), {
@@ -428,7 +430,11 @@ describe('Chat Edit API Route', () => {
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(200)
expect(mockDelete).toHaveBeenCalled()
expect(mockPerformChatUndeploy).toHaveBeenCalledWith({
chatId: 'chat-123',
userId: 'user-id',
workspaceId: 'workspace-123',
})
const data = await response.json()
expect(data.message).toBe('Chat deployment deleted successfully')
})
@@ -451,7 +457,11 @@ describe('Chat Edit API Route', () => {
expect(response.status).toBe(200)
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'admin-user-id')
expect(mockDelete).toHaveBeenCalled()
expect(mockPerformChatUndeploy).toHaveBeenCalledWith({
chatId: 'chat-123',
userId: 'admin-user-id',
workspaceId: 'workspace-123',
})
})
})
})

View File

@@ -9,6 +9,7 @@ import { getSession } from '@/lib/auth'
import { isDev } from '@/lib/core/config/feature-flags'
import { encryptSecret } from '@/lib/core/security/encryption'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { performChatUndeploy } from '@/lib/workflows/orchestration'
import { deployWorkflow } from '@/lib/workflows/persistence/utils'
import { checkChatAccess } from '@/app/api/chat/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -270,33 +271,25 @@ export async function DELETE(
return createErrorResponse('Unauthorized', 401)
}
const {
hasAccess,
chat: chatRecord,
workspaceId: chatWorkspaceId,
} = await checkChatAccess(chatId, session.user.id)
const { hasAccess, workspaceId: chatWorkspaceId } = await checkChatAccess(
chatId,
session.user.id
)
if (!hasAccess) {
return createErrorResponse('Chat not found or access denied', 404)
}
await db.delete(chat).where(eq(chat.id, chatId))
logger.info(`Chat "${chatId}" deleted successfully`)
recordAudit({
workspaceId: chatWorkspaceId || null,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.CHAT_DELETED,
resourceType: AuditResourceType.CHAT,
resourceId: chatId,
resourceName: chatRecord?.title || chatId,
description: `Deleted chat deployment "${chatRecord?.title || chatId}"`,
request: _request,
const result = await performChatUndeploy({
chatId,
userId: session.user.id,
workspaceId: chatWorkspaceId,
})
if (!result.success) {
return createErrorResponse(result.error || 'Failed to delete chat', 500)
}
return createSuccessResponse({
message: 'Chat deployment deleted successfully',
})

View File

@@ -3,7 +3,7 @@
*
* @vitest-environment node
*/
import { auditMock, createEnvMock } from '@sim/testing'
import { createEnvMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -12,66 +12,51 @@ const {
mockFrom,
mockWhere,
mockLimit,
mockInsert,
mockValues,
mockReturning,
mockCreateSuccessResponse,
mockCreateErrorResponse,
mockEncryptSecret,
mockCheckWorkflowAccessForChatCreation,
mockDeployWorkflow,
mockPerformChatDeploy,
mockGetSession,
mockUuidV4,
} = vi.hoisted(() => ({
mockSelect: vi.fn(),
mockFrom: vi.fn(),
mockWhere: vi.fn(),
mockLimit: vi.fn(),
mockInsert: vi.fn(),
mockValues: vi.fn(),
mockReturning: vi.fn(),
mockCreateSuccessResponse: vi.fn(),
mockCreateErrorResponse: vi.fn(),
mockEncryptSecret: vi.fn(),
mockCheckWorkflowAccessForChatCreation: vi.fn(),
mockDeployWorkflow: vi.fn(),
mockPerformChatDeploy: vi.fn(),
mockGetSession: vi.fn(),
mockUuidV4: vi.fn(),
}))
vi.mock('@/lib/audit/log', () => auditMock)
vi.mock('@sim/db', () => ({
db: {
select: mockSelect,
insert: mockInsert,
},
}))
vi.mock('@sim/db/schema', () => ({
chat: { userId: 'userId', identifier: 'identifier' },
chat: { userId: 'userId', identifier: 'identifier', archivedAt: 'archivedAt' },
workflow: { id: 'id', userId: 'userId', isDeployed: 'isDeployed' },
}))
vi.mock('drizzle-orm', () => ({
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })),
}))
vi.mock('@/app/api/workflows/utils', () => ({
createSuccessResponse: mockCreateSuccessResponse,
createErrorResponse: mockCreateErrorResponse,
}))
vi.mock('@/lib/core/security/encryption', () => ({
encryptSecret: mockEncryptSecret,
}))
vi.mock('uuid', () => ({
v4: mockUuidV4,
}))
vi.mock('@/app/api/chat/utils', () => ({
checkWorkflowAccessForChatCreation: mockCheckWorkflowAccessForChatCreation,
}))
vi.mock('@/lib/workflows/persistence/utils', () => ({
deployWorkflow: mockDeployWorkflow,
vi.mock('@/lib/workflows/orchestration', () => ({
performChatDeploy: mockPerformChatDeploy,
}))
vi.mock('@/lib/auth', () => ({
@@ -94,10 +79,6 @@ describe('Chat API Route', () => {
mockSelect.mockReturnValue({ from: mockFrom })
mockFrom.mockReturnValue({ where: mockWhere })
mockWhere.mockReturnValue({ limit: mockLimit })
mockInsert.mockReturnValue({ values: mockValues })
mockValues.mockReturnValue({ returning: mockReturning })
mockUuidV4.mockReturnValue('test-uuid')
mockCreateSuccessResponse.mockImplementation((data) => {
return new Response(JSON.stringify(data), {
@@ -113,12 +94,10 @@ describe('Chat API Route', () => {
})
})
mockEncryptSecret.mockResolvedValue({ encrypted: 'encrypted-password' })
mockDeployWorkflow.mockResolvedValue({
mockPerformChatDeploy.mockResolvedValue({
success: true,
version: 1,
deployedAt: new Date(),
chatId: 'test-uuid',
chatUrl: 'http://localhost:3000/chat/test-chat',
})
})
@@ -277,7 +256,6 @@ describe('Chat API Route', () => {
hasAccess: true,
workflow: { userId: 'user-id', workspaceId: null, isDeployed: true },
})
mockReturning.mockResolvedValue([{ id: 'test-uuid' }])
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
@@ -287,6 +265,13 @@ describe('Chat API Route', () => {
expect(response.status).toBe(200)
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
expect(mockPerformChatDeploy).toHaveBeenCalledWith(
expect.objectContaining({
workflowId: 'workflow-123',
userId: 'user-id',
identifier: 'test-chat',
})
)
})
it('should allow chat deployment when user has workspace admin permission', async () => {
@@ -309,7 +294,6 @@ describe('Chat API Route', () => {
hasAccess: true,
workflow: { userId: 'other-user-id', workspaceId: 'workspace-123', isDeployed: true },
})
mockReturning.mockResolvedValue([{ id: 'test-uuid' }])
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
@@ -319,6 +303,12 @@ describe('Chat API Route', () => {
expect(response.status).toBe(200)
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
expect(mockPerformChatDeploy).toHaveBeenCalledWith(
expect.objectContaining({
workflowId: 'workflow-123',
workspaceId: 'workspace-123',
})
)
})
it('should reject when workflow is in workspace but user lacks admin permission', async () => {
@@ -383,7 +373,7 @@ describe('Chat API Route', () => {
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
})
it('should auto-deploy workflow if not already deployed', async () => {
it('should call performChatDeploy for undeployed workflow', async () => {
mockGetSession.mockResolvedValue({
user: { id: 'user-id', email: 'user@example.com' },
})
@@ -403,7 +393,6 @@ describe('Chat API Route', () => {
hasAccess: true,
workflow: { userId: 'user-id', workspaceId: null, isDeployed: false },
})
mockReturning.mockResolvedValue([{ id: 'test-uuid' }])
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
@@ -412,10 +401,12 @@ describe('Chat API Route', () => {
const response = await POST(req)
expect(response.status).toBe(200)
expect(mockDeployWorkflow).toHaveBeenCalledWith({
workflowId: 'workflow-123',
deployedBy: 'user-id',
})
expect(mockPerformChatDeploy).toHaveBeenCalledWith(
expect.objectContaining({
workflowId: 'workflow-123',
userId: 'user-id',
})
)
})
})
})

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