chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,25 @@
'use client'
import { Chip } from '@sim/emcn'
import { AuthModal } from '@/app/(landing)/components/auth-modal/auth-modal'
import { trackLandingCta } from '@/app/(landing)/track-landing-cta'
interface IntegrationCtaButtonProps {
children: React.ReactNode
label: string
}
export function IntegrationCtaButton({ children, label }: IntegrationCtaButtonProps) {
return (
<AuthModal defaultView='signup' source='integrations'>
<Chip
variant='primary'
onClick={() =>
trackLandingCta({ label, section: 'integrations', destination: 'auth_modal' })
}
>
{children}
</Chip>
</AuthModal>
)
}
@@ -0,0 +1,38 @@
'use client'
import { cn } from '@sim/emcn'
import { useRouter } from 'next/navigation'
import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
import { trackLandingCta } from '@/app/(landing)/track-landing-cta'
interface TemplateCardButtonProps {
/**
* Curated template prompt, already rewritten to `@`-mention form by the
* page's server-side `mentionifyPromptForNames` (registry-free, so the
* landing client bundle never pulls the full block registry). Stored verbatim
* for the home input to consume after signup.
*/
prompt: string
className?: string
children: React.ReactNode
}
export function TemplateCardButton({ prompt, className, children }: TemplateCardButtonProps) {
const router = useRouter()
function savePromptAndNavigate() {
LandingPromptStorage.store(prompt)
trackLandingCta({ label: 'Template card', section: 'integrations', destination: '/signup' })
router.push('/signup')
}
return (
<button
type='button'
onClick={savePromptAndNavigate}
className={cn('w-full text-left', className)}
>
{children}
</button>
)
}
@@ -0,0 +1,9 @@
import { Loader } from '@sim/emcn'
export default function IntegrationDetailLoading() {
return (
<div className='flex min-h-[60vh] items-center justify-center bg-[var(--bg)]'>
<Loader animate className='size-6 text-[var(--text-muted)]' />
</div>
)
}
@@ -0,0 +1,48 @@
import { notFound } from 'next/navigation'
import integrationsJson from '@/lib/integrations/integrations.json'
import type { AuthType, Integration } from '@/lib/integrations/types'
import { createLandingOgImage } from '@/app/(landing)/og-utils'
export const contentType = 'image/png'
export const size = {
width: 1200,
height: 630,
}
/** Raw catalog JSON, not the barrel - keeps `@/blocks/registry` out of the OG bundle. */
const integrations = integrationsJson.integrations as readonly Integration[]
const bySlug = new Map(integrations.map((i) => [i.slug, i]))
const AUTH_LABEL: Record<AuthType, string> = {
oauth: 'One-click OAuth',
'api-key': 'API key auth',
none: 'No auth required',
}
export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const integration = bySlug.get(slug)
if (!integration) {
notFound()
}
const pills = [
integration.operationCount > 0
? `${integration.operationCount} tool${integration.operationCount === 1 ? '' : 's'}`
: null,
integration.triggerCount > 0
? `${integration.triggerCount} real-time trigger${integration.triggerCount === 1 ? '' : 's'}`
: null,
AUTH_LABEL[integration.authType],
'Free to start',
].filter((pill): pill is string => pill !== null)
return createLandingOgImage({
eyebrow: 'Sim integration',
title: `${integration.name} Integration`,
subtitle: integration.description,
pills,
domainLabel: `sim.ai/integrations/${slug}`,
})
}
@@ -0,0 +1,969 @@
import { ChipLink } from '@sim/emcn'
import { truncate } from '@sim/utils/string'
import type { Metadata } from 'next'
import Image from 'next/image'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { SITE_URL } from '@/lib/core/utils/urls'
import {
type AuthType,
blockTypeToIconMap,
type FAQItem,
formatIntegrationType,
INTEGRATIONS,
INTEGRATIONS_UPDATED_AT,
type Integration,
} from '@/lib/integrations'
import { BackLink } from '@/app/(landing)/components'
import { JsonLd } from '@/app/(landing)/components/json-ld'
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
import { ShareButton } from '@/app/(landing)/components/share-button'
import { IntegrationCtaButton } from '@/app/(landing)/integrations/(shell)/[slug]/components/integration-cta-button'
import { TemplateCardButton } from '@/app/(landing)/integrations/(shell)/[slug]/components/template-card-button'
import { IntegrationIcon } from '@/app/(landing)/integrations/components/integration-icon'
import { INTEGRATION_SEO } from '@/app/(landing)/integrations/data/seo-content'
import { getTemplatesForBlock } from '@/blocks/registry'
const allIntegrations = INTEGRATIONS
const INTEGRATION_COUNT = allIntegrations.length
const baseUrl = SITE_URL
/** Fast O(1) lookups - avoids repeated linear scans inside render loops. */
const bySlug = new Map(allIntegrations.map((i) => [i.slug, i]))
const byType = new Map(allIntegrations.map((i) => [i.type, i]))
export const dynamicParams = false
/**
* Returns up to `limit` related integration slugs.
*
* Scoring (additive):
* +3 per shared operation name - strongest signal (same capability)
* +2 per shared operation word - weaker signal (e.g. both have "create" ops)
* +2 same integration category - topical relevance (both CRMs, both devops)
* +1 same auth type - comparable setup experience
*
* Every integration gets a score, so the sidebar always has suggestions.
* Ties are broken by alphabetical slug order for determinism.
*/
function getRelatedSlugs(
slug: string,
operations: Integration['operations'],
authType: AuthType,
integrationType: Integration['integrationType'],
limit = 6
): string[] {
const currentOpNames = new Set(operations.map((o) => o.name.toLowerCase()))
const currentOpWords = new Set(
operations.flatMap((o) =>
o.name
.toLowerCase()
.split(/\s+/)
.filter((w) => w.length > 3)
)
)
return allIntegrations
.reduce<Array<{ slug: string; score: number }>>((scored, i) => {
if (i.slug === slug) return scored
const sharedNames = i.operations.filter((o) =>
currentOpNames.has(o.name.toLowerCase())
).length
const sharedWords = i.operations.filter((o) =>
o.name
.toLowerCase()
.split(/\s+/)
.some((w) => w.length > 3 && currentOpWords.has(w))
).length
const sameCategory = i.integrationType === integrationType ? 2 : 0
const sameAuth = i.authType === authType ? 1 : 0
scored.push({
slug: i.slug,
score: sharedNames * 3 + sharedWords * 2 + sameCategory + sameAuth,
})
return scored
}, [])
.sort((a, b) => b.score - a.score || a.slug.localeCompare(b.slug))
.slice(0, limit)
.map(({ slug: s }) => s)
}
const AUTH_STEP: Record<AuthType, (name: string) => string> = {
oauth: (name) =>
`Connect your ${name} account with one-click OAuth, with no credentials to copy.`,
'api-key': (name) =>
`Paste your ${name} API key to authenticate. You can find it in your ${name} account settings.`,
none: () => 'No authentication is needed, so the block works as soon as you drop it in.',
}
/** Human-readable catalog refresh date for the visible last-updated line. */
const UPDATED_AT_DISPLAY = new Date(`${INTEGRATIONS_UPDATED_AT}T00:00:00Z`).toLocaleDateString(
'en-US',
{ year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }
)
/**
* Ensures autogenerated prose can be safely composed with a following sentence.
*/
function sentenceWithTerminalPunctuation(value: string): string {
const trimmedValue = value.trim()
return /[.!?]$/.test(trimmedValue) ? trimmedValue : `${trimmedValue}.`
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
* Server-side rewrite of bare integration names in a curated template prompt
* to `@`-mention form (`Slack` → `@Slack`) so the prompt chips with brand
* icons once it is populated into the Mothership home input after signup -
* the home auto-mention pipeline only chips token-starting `@` mentions, so
* curated prompts must opt in.
*
* Unlike the workspace surface, which calls `mentionifyIntegrations` from
* `@/blocks/integration-matcher`, this runs only over the handful of names a
* template actually references (its owner + `otherBlockTypes`) and lives in a
* Server Component, so it never pulls the full block/tool/icon registry into
* the landing client bundle. Whole-token, longest-first matching with
* lookarounds mirrors the canonical matcher; idempotent on already-prefixed
* names.
*/
function mentionifyPromptForNames(prompt: string, names: readonly string[]): string {
const unique = Array.from(new Set(names.filter((n) => n.trim().length >= 2))).sort(
(a, b) => b.length - a.length
)
if (unique.length === 0) return prompt
const regex = new RegExp(
`(?<![A-Za-z0-9_@])(${unique.map(escapeRegex).join('|')})(?![A-Za-z0-9_])`,
'gi'
)
return prompt.replace(regex, (match) => `@${match}`)
}
/** Lowercases only the first character so acronyms in tool names survive. */
function lowercaseFirst(value: string): string {
return value.charAt(0).toLowerCase() + value.slice(1)
}
/**
* The ordered integration-icon chain for a template card - one tile per block
* type in flow order, separated by arrows. Resolves each type through its
* versioned aliases so v2/v3 blocks reuse the base icon and name.
*/
function TemplateIconRow({ allTypes }: { allTypes: string[] }) {
return (
<>
{allTypes.map((bt, idx) => {
const resolvedBt = byType.get(bt)
? bt
: byType.get(`${bt}_v2`)
? `${bt}_v2`
: byType.get(`${bt}_v3`)
? `${bt}_v3`
: bt
const int = byType.get(resolvedBt)
const ToolIcon = blockTypeToIconMap[resolvedBt]
return (
<span key={bt} className='inline-flex items-center gap-1.5'>
{idx > 0 && (
<span className='text-[11px] text-[var(--text-subtle)]' aria-hidden='true'>
</span>
)}
<IntegrationIcon
bgColor={int?.bgColor ?? 'var(--surface-active)'}
name={int?.name ?? bt}
Icon={ToolIcon}
as='span'
className='size-6 rounded-[4px]'
iconClassName='size-3.5'
fallbackClassName='text-[10px]'
aria-hidden='true'
/>
</span>
)
})}
</>
)
}
/** Joins items into readable prose: "a", "a and b", or "a, b, and c". */
function toProseList(items: string[]): string {
if (items.length <= 1) return items[0] ?? ''
if (items.length === 2) return `${items[0]} and ${items[1]}`
return `${items.slice(0, -1).join(', ')}, and ${items[items.length - 1]}`
}
/** "a" vs "an" for a service name; U-names read as "you", so they take "a". */
function articleFor(name: string): string {
return /^[aeio]/i.test(name) ? 'an' : 'a'
}
/**
* Generates the per-integration FAQ. Answers lead with a direct answer and
* carry integration-specific facts; catalog-generic questions live once on
* the /integrations index FAQ instead of repeating across every page.
*/
function buildFAQs(integration: Integration, relatedNames: string[]): FAQItem[] {
const { name, description, operations, triggers, authType } = integration
const faqDescription = sentenceWithTerminalPunctuation(description)
const opCount = operations.length
const triggerCount = triggers.length
const topOpNames = operations.slice(0, 5).map((o) => o.name)
const firstOp = operations[0]
const firstTrigger = triggers[0]
const pairings = relatedNames.slice(0, 2)
const toolsPhrase = `${opCount} ${name} tool${opCount === 1 ? '' : 's'}`
const triggersPhrase = `${triggerCount} real-time trigger${triggerCount === 1 ? '' : 's'}`
const capabilityPhrase = [
opCount > 0 ? toolsPhrase : null,
triggerCount > 0 ? triggersPhrase : null,
]
.filter((part): part is string => part !== null)
.join(' and ')
const triggerNames = triggers.map((t) => t.name)
const triggerListPhrase =
triggerCount > 6
? `${triggerNames.slice(0, 6).join(', ')}, and ${triggerCount - 6} more`
: toProseList(triggerNames)
const firstTriggerWhen = firstTrigger?.description.match(/^trigger workflow (when .+)$/i)?.[1]
const connectFinalStep = firstOp
? `Pick a tool such as "${firstOp.name}", wire up its inputs, and click Run, and your agent is live.`
: triggerCount > 0
? `Choose the ${name} event you want to listen for, and your agent runs automatically from then on.`
: `Configure the block's inputs and click Run, and your agent is live.`
const faqs: FAQItem[] = [
{
question: `What is Sim's ${name} integration?`,
answer: `Sim's ${name} integration ${capabilityPhrase ? `adds ${capabilityPhrase} to` : `connects ${name} to`} the AI agents you build in Sim's visual workflow builder — you build it all visually. ${faqDescription}${
pairings.length === 2
? ` Teams often pair ${name} with ${pairings[0]} and ${pairings[1]} in the same agent.`
: ''
}`,
},
...(opCount > 0
? [
{
question: `What can I automate with ${name} in Sim?`,
answer: `You can ${toProseList(topOpNames.map(lowercaseFirst))} with ${name} in Sim${
opCount > 5 ? `, plus ${opCount - 5} more ${name} tools listed on this page` : ''
}. ${opCount === 1 ? 'It runs' : 'Each runs'} as a tool inside an AI agent block, so an agent can chain ${name} with ${
pairings.length === 2
? `services like ${pairings[0]} and ${pairings[1]}`
: 'any other connected service'
} and apply LLM reasoning between steps.`,
},
]
: []),
{
question: `How do I connect ${name} to Sim?`,
answer: `Connecting ${name} takes about five minutes: (1) Create a free account at sim.ai. (2) Create an agent in your workspace. (3) Drag ${articleFor(name)} ${name} block onto the workflow builder. (4) ${AUTH_STEP[authType](name)} (5) ${connectFinalStep}`,
},
...(firstOp && opCount >= 2
? [
{
question: `How do I ${lowercaseFirst(firstOp.name)} with ${name} in Sim?`,
answer: `Add ${articleFor(name)} ${name} block to your agent and select "${firstOp.name}" as the tool.${
firstOp.description ? ` ${sentenceWithTerminalPunctuation(firstOp.description)}` : ''
} Fill in the required fields. Inputs can reference outputs from earlier steps, such as text generated by an AI block or data fetched from another integration, and you build it all visually.`,
},
]
: []),
...(triggerCount > 0
? [
{
question: `How do I trigger a Sim agent from ${name} automatically?`,
answer: `Add ${articleFor(name)} ${name} trigger block to your agent and copy its generated webhook URL into ${name}'s webhook settings. Sim supports ${triggersPhrase} for ${name}: ${triggerListPhrase}. Once configured, every matching ${name} event starts your agent instantly, no polling, no delay.`,
},
{
question: `What data does Sim receive when a ${name} event triggers an agent?`,
answer: `Sim receives the full event payload ${name} sends, typically the record or object that changed, plus metadata like the event type and timestamp.${
firstTriggerWhen
? ` For example, the "${firstTrigger.name}" trigger fires ${sentenceWithTerminalPunctuation(firstTriggerWhen)}`
: ''
} Every field in the payload is available as a variable you can pass to AI blocks, conditions, or other integrations.`,
},
]
: []),
]
return faqs
}
export async function generateStaticParams() {
return allIntegrations.map((i) => ({ slug: i.slug }))
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>
}): Promise<Metadata> {
const { slug } = await params
const integration = bySlug.get(slug)
if (!integration) return {}
const { name, description, operations } = integration
const opSample = operations
.slice(0, 3)
.map((o) => o.name)
.join(', ')
const categoryLabel = formatIntegrationType(integration.integrationType)
const seo = INTEGRATION_SEO[slug]
const metaDesc =
seo?.description ??
`Automate ${name} with AI agents in Sim. ${sentenceWithTerminalPunctuation(truncate(description, 100))} Free to start.`
return {
// A hand-authored SEO title is rendered verbatim (it carries its own brand
// suffix); otherwise the bare name flows through the root `%s | Sim` template.
title: seo?.title ? { absolute: seo.title } : `${name} Integration`,
description: metaDesc,
keywords: seo?.keywords ?? [
`${name} automation`,
`${name} integration`,
`automate ${name}`,
`connect ${name}`,
`${name} AI agent`,
`${name} AI automation`,
...(opSample ? [`${name} ${opSample}`] : []),
`${categoryLabel} integration`,
...(integration.tags ?? []).map((tag) => `${name} ${tag.replace(/-/g, ' ')}`),
...(integration.triggerCount > 0 ? [`${name} webhook`, `${name} trigger`] : []),
'AI workspace integrations',
'AI agent integrations',
'AI agent builder',
],
// og:image/twitter:image come from the sibling opengraph-image.tsx -
// Next serves it at a hash-suffixed URL, so hardcoding it here 404s.
openGraph: {
title: seo?.title ?? `${name} Integration | Sim AI Workspace`,
description:
seo?.description ??
`Connect ${name} to ${INTEGRATION_COUNT - 1}+ tools using AI agents. ${sentenceWithTerminalPunctuation(truncate(description, 100))}`,
url: `${baseUrl}/integrations/${slug}`,
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: seo?.title ?? `${name} Integration | Sim`,
description:
seo?.description ??
`Automate ${name} with AI agents in Sim. Connect to ${INTEGRATION_COUNT - 1}+ tools. Free to start.`,
},
alternates: { canonical: `${baseUrl}/integrations/${slug}` },
}
}
export default async function IntegrationPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const integration = bySlug.get(slug)
if (!integration) notFound()
const { name, description, longDescription, bgColor, docsUrl, operations, triggers, authType } =
integration
const landingContent = integration.landingContent
const seo = INTEGRATION_SEO[slug]
const overviewBody = seo?.overview ?? longDescription
const IconComponent = blockTypeToIconMap[integration.type]
const categoryLabel = formatIntegrationType(integration.integrationType)
const relatedSlugs = getRelatedSlugs(slug, operations, authType, integration.integrationType)
const relatedIntegrations = relatedSlugs
.map((s) => bySlug.get(s))
.filter((i): i is Integration => i !== undefined)
const faqs = buildFAQs(
integration,
relatedIntegrations.map((i) => i.name)
)
const matchingTemplates = getTemplatesForBlock(integration.type)
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl },
{
'@type': 'ListItem',
position: 2,
name: 'Integrations',
item: `${baseUrl}/integrations`,
},
{ '@type': 'ListItem', position: 3, name, item: `${baseUrl}/integrations/${slug}` },
],
}
const softwareAppJsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: `${name} Integration`,
description,
url: `${baseUrl}/integrations/${slug}`,
applicationCategory: 'BusinessApplication',
applicationSubCategory: categoryLabel,
operatingSystem: 'Web',
featureList: operations.map((o) => o.name),
...(integration.tags?.length
? { keywords: integration.tags.map((tag) => tag.replace(/-/g, ' ')).join(', ') }
: {}),
dateModified: INTEGRATIONS_UPDATED_AT,
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
}
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqs.map(({ question, answer }) => ({
'@type': 'Question',
name: question,
acceptedAnswer: { '@type': 'Answer', text: answer },
})),
}
return (
<section className='bg-[var(--bg)]'>
<JsonLd data={breadcrumbJsonLd} />
<JsonLd data={softwareAppJsonLd} />
<JsonLd data={faqJsonLd} />
{/* Hero */}
<div className='mx-auto w-full max-w-[1460px] px-20 pt-[112px] max-sm:px-5 max-sm:pt-20 max-lg:px-8'>
<div className='mb-6'>
<BackLink href='/integrations' label='Back to Integrations' />
</div>
{/* Hero content */}
<div className='mb-6 flex items-center gap-5'>
<IntegrationIcon
bgColor={bgColor}
name={name}
Icon={IconComponent}
className='size-12 rounded-xl border border-[var(--border-1)]'
iconClassName='size-6'
fallbackClassName='text-[20px]'
aria-hidden='true'
/>
<div>
<h1
id='integration-heading'
className='text-[28px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em] sm:text-[36px] lg:text-[44px]'
>
{seo?.h1 ?? name}
</h1>
</div>
</div>
<p className='mb-3 max-w-[700px] text-[var(--text-body)] text-base leading-[150%] tracking-[0.02em]'>
{seo?.tagline ?? description}
</p>
<p className='sr-only'>
{name} is a {categoryLabel} integration for Sim, the AI workspace where teams build and
deploy AI agents. Sim&apos;s {name} integration provides{' '}
{[
operations.length > 0
? `${operations.length} ${name} tool${operations.length === 1 ? '' : 's'}`
: null,
triggers.length > 0
? `${triggers.length} real-time trigger${triggers.length === 1 ? '' : 's'}`
: null,
]
.filter((part): part is string => part !== null)
.join(' and ') || `a ${name} connection`}{' '}
that AI agents can use inside Sim&apos;s visual workflow builder.{' '}
{authType === 'oauth'
? `${name} connects with one-click OAuth.`
: authType === 'api-key'
? `${name} connects with an API key.`
: `${name} requires no authentication.`}{' '}
Free to start at sim.ai.
</p>
{/* CTAs */}
<div className='flex flex-wrap gap-2'>
<IntegrationCtaButton label='Start building free'>
Start building free
</IntegrationCtaButton>
<ChipLink
href={docsUrl}
target='_blank'
rel='noopener noreferrer'
className='border border-[var(--border-1)]'
>
View docs
</ChipLink>
<ShareButton url={`${baseUrl}/integrations/${slug}`} title={`${name} Integration`} />
</div>
<p className='mt-5 text-[var(--text-muted)] text-xs'>
Last updated <time dateTime={INTEGRATIONS_UPDATED_AT}>{UPDATED_AT_DISPLAY}</time>
</p>
</div>
{/* Full-width divider */}
<div className='mt-8 h-px w-full bg-[var(--border)]' />
{/* Border-railed content */}
<div className='mx-20 max-w-[1300px] border-[var(--border)] border-x max-sm:mx-5 max-lg:mx-8 min-[1460px]:mx-auto'>
{/* Overview */}
{overviewBody && (
<>
<section aria-labelledby='overview-heading' className='px-6 py-10'>
<h2
id='overview-heading'
className='mb-4 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em]'
>
Overview
</h2>
<p className='text-[15px] text-[var(--text-body)] leading-[150%] tracking-[0.02em]'>
{overviewBody}
</p>
</section>
<div className='h-px w-full bg-[var(--border)]' />
</>
)}
{/* Install / Add to workspace (integration-specific) */}
{landingContent?.install && (
<>
<section aria-labelledby='install-heading' className='px-6 py-10'>
<h2
id='install-heading'
className='mb-4 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em]'
>
{landingContent.install.heading}
</h2>
<p className='mb-6 max-w-[700px] text-[15px] text-[var(--text-body)] leading-[150%] tracking-[0.02em]'>
{landingContent.install.intro}
</p>
<ol className='space-y-4' aria-label={`Steps to add ${name}`}>
{landingContent.install.steps.map((item, index) => (
<li key={item.title} className='flex gap-4'>
<span
className='mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full border border-[var(--border-1)] text-[11px] text-[var(--text-muted)]'
aria-hidden='true'
>
{String(index + 1).padStart(2, '0')}
</span>
<div>
<h3 className='mb-1 text-[15px] text-[var(--text-primary)] tracking-[-0.02em]'>
{item.title}
</h3>
<p className='text-[14px] text-[var(--text-body)] leading-[150%] tracking-[0.02em]'>
{item.body}
</p>
</div>
</li>
))}
</ol>
<div className='mt-8 flex flex-wrap gap-2'>
<IntegrationCtaButton label={`Add to ${name}`}>Add to {name}</IntegrationCtaButton>
</div>
</section>
<div className='h-px w-full bg-[var(--border)]' />
</>
)}
{/* Privacy & data (integration-specific) */}
{landingContent?.privacy && (
<>
<section aria-labelledby='privacy-heading' className='px-6 py-10'>
<h2
id='privacy-heading'
className='mb-4 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em]'
>
Privacy & data
</h2>
<p className='max-w-[700px] text-[15px] text-[var(--text-body)] leading-[150%] tracking-[0.02em]'>
{landingContent.privacy.body}{' '}
<Link
href={landingContent.privacy.href}
className='text-[var(--text-primary)] underline underline-offset-2 hover:text-[var(--text-primary)]'
>
Privacy Policy
</Link>
.
</p>
</section>
<div className='h-px w-full bg-[var(--border)]' />
</>
)}
{/* AI-generated content disclaimer (integration-specific) */}
{landingContent?.aiDisclaimer && (
<>
<section aria-labelledby='ai-disclaimer-heading' className='px-6 py-10'>
<h2
id='ai-disclaimer-heading'
className='mb-4 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em]'
>
AI-generated content
</h2>
<p className='max-w-[700px] text-[15px] text-[var(--text-body)] leading-[150%] tracking-[0.02em]'>
{landingContent.aiDisclaimer}
</p>
</section>
<div className='h-px w-full bg-[var(--border)]' />
</>
)}
{/* How to automate */}
<section aria-labelledby='how-it-works-heading' className='px-6 py-10'>
<h2
id='how-it-works-heading'
className='mb-6 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em]'
>
How to automate {name} with Sim
</h2>
<ol className='space-y-4' aria-label='Steps to set up automation'>
{[
{
step: '01',
title: 'Create a free account',
body: 'Sign up at sim.ai in seconds. No credit card required. Your workspace is ready immediately.',
},
{
step: '02',
title: `Add ${articleFor(name)} ${name} block`,
body:
authType === 'oauth'
? `Open your workspace, drag ${articleFor(name)} ${name} block onto the workflow builder, and connect your account with one-click OAuth.`
: authType === 'api-key'
? `Open your workspace, drag ${articleFor(name)} ${name} block onto the workflow builder, and paste in your ${name} API key.`
: `Open your workspace, drag ${articleFor(name)} ${name} block onto the workflow builder. No authentication is needed.`,
},
{
step: '03',
title: 'Configure, connect, and run',
body: `Pick the tool you need, wire in an AI agent for reasoning or data transformation, and run. Your ${name} automation is live.`,
},
].map(({ step, title, body }) => (
<li key={step} className='flex gap-4'>
<span
className='mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full border border-[var(--border-1)] text-[11px] text-[var(--text-muted)]'
aria-hidden='true'
>
{step}
</span>
<div>
<h3 className='mb-1 text-[15px] text-[var(--text-primary)] tracking-[-0.02em]'>
{title}
</h3>
<p className='text-[14px] text-[var(--text-body)] leading-[150%] tracking-[0.02em]'>
{body}
</p>
</div>
</li>
))}
</ol>
</section>
<div className='h-px w-full bg-[var(--border)]' />
{/* Triggers - rows */}
{triggers.length > 0 && (
<section aria-labelledby='triggers-heading'>
<div className='px-6 pt-10 pb-4'>
<div className='mb-2 flex items-center gap-2.5'>
<span className='relative flex size-2' aria-hidden='true'>
<span className='absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75' />
<span className='relative inline-flex size-2 rounded-full bg-emerald-500' />
</span>
<h2
id='triggers-heading'
className='text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em]'
>
Real-time triggers
</h2>
</div>
<p className='text-[14px] text-[var(--text-body)] leading-[150%] tracking-[0.02em]'>
{seo?.triggersIntro ?? (
<>
Connect {articleFor(name)} {name} webhook to Sim and your agent runs the instant
an event happens, no polling, no delay.
</>
)}
</p>
</div>
<div className='h-px w-full bg-[var(--border)]' />
{triggers.map((trigger) => (
<div key={trigger.id}>
<div className='flex items-start gap-4 px-6 py-4'>
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
<p className='text-[14px] text-[var(--text-primary)] leading-snug tracking-[-0.02em]'>
{trigger.name}
</p>
{trigger.description && (
<p className='text-[12px] text-[var(--text-muted)] leading-[150%]'>
{trigger.description}
</p>
)}
</div>
</div>
<div className='h-px w-full bg-[var(--border)]' />
</div>
))}
</section>
)}
{/* Workflow templates - horizontal cards */}
{matchingTemplates.length > 0 && (
<section aria-labelledby='templates-heading'>
<div className='px-6 pt-10 pb-4'>
<h2
id='templates-heading'
className='mb-2 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em]'
>
Agent templates
</h2>
<p className='text-[14px] text-[var(--text-body)] tracking-[0.02em]'>
{seo?.templatesIntro ??
`Ready-to-use templates featuring ${name}. Click any to build it instantly.`}
</p>
</div>
<div className='h-px w-full bg-[var(--border)]' />
{(() => {
const isOdd = matchingTemplates.length % 2 === 1
const pairedTemplates = isOdd ? matchingTemplates.slice(0, -1) : matchingTemplates
const lastTemplate = isOdd ? matchingTemplates[matchingTemplates.length - 1] : null
const resolveTypes = (template: (typeof matchingTemplates)[number]) => [
integration.type,
...template.otherBlockTypes,
]
const resolveDisplayName = (bt: string): string | null => {
const resolvedBt = byType.get(bt)
? bt
: byType.get(`${bt}_v2`)
? `${bt}_v2`
: byType.get(`${bt}_v3`)
? `${bt}_v3`
: bt
return byType.get(resolvedBt)?.name ?? null
}
/**
* The curated template prompt rewritten so the integrations it
* references chip in the home input after signup. Computed
* server-side from the template's own integration set - never the
* full registry - so the visible card text stays raw while the
* stored prompt opts into mention treatment.
*/
const storedPrompt = (template: (typeof matchingTemplates)[number]) =>
mentionifyPromptForNames(
template.prompt,
resolveTypes(template)
.map(resolveDisplayName)
.filter((n): n is string => n !== null)
)
return (
<>
{/* Paired rows of 2 */}
{Array.from({ length: Math.ceil(pairedTemplates.length / 2) }, (_, rowIdx) => {
const row = pairedTemplates.slice(rowIdx * 2, rowIdx * 2 + 2)
return (
<div key={rowIdx}>
<nav
aria-label={`Template row ${rowIdx + 1}`}
className='flex flex-col sm:flex-row'
>
{row.map((template) => (
<TemplateCardButton
key={template.title}
prompt={storedPrompt(template)}
className='group flex flex-1 flex-col gap-4 border-[var(--border)] border-t p-6 transition-colors first:border-t-0 hover:bg-[var(--surface-hover)] sm:border-t-0 sm:border-l sm:first:border-l-0'
>
<div className='flex items-center gap-1.5'>
<TemplateIconRow allTypes={resolveTypes(template)} />
</div>
<div className='flex flex-col gap-2'>
<h3 className='text-[14px] text-[var(--text-primary)] leading-snug tracking-[-0.02em]'>
{template.title}
</h3>
<p className='line-clamp-2 text-[var(--text-muted)] text-sm leading-[150%]'>
{template.prompt}
</p>
</div>
</TemplateCardButton>
))}
</nav>
<div className='h-px w-full bg-[var(--border)]' />
</div>
)
})}
{/* Last template as a full-width row when odd */}
{lastTemplate && (
<>
<TemplateCardButton
prompt={storedPrompt(lastTemplate)}
className='group/link flex items-center gap-4 px-6 py-4 transition-colors hover:bg-[var(--surface-hover)]'
>
<div className='flex items-center gap-1.5'>
<TemplateIconRow allTypes={resolveTypes(lastTemplate)} />
</div>
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
<h3 className='text-[14px] text-[var(--text-primary)] leading-snug tracking-[-0.02em]'>
{lastTemplate.title}
</h3>
<p className='line-clamp-1 text-[12px] text-[var(--text-muted)] leading-[150%]'>
{lastTemplate.prompt}
</p>
</div>
</TemplateCardButton>
<div className='h-px w-full bg-[var(--border)]' />
</>
)}
</>
)
})()}
</section>
)}
{/* Supported tools - rows */}
{operations.length > 0 && (
<section aria-labelledby='tools-heading'>
<div className='px-6 pt-10 pb-4'>
<h2
id='tools-heading'
className='mb-2 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em]'
>
Supported tools
</h2>
<p className='text-[14px] text-[var(--text-body)] tracking-[0.02em]'>
{operations.length} {name} tool{operations.length === 1 ? '' : 's'} available in Sim
{seo?.toolsSubtitleSuffix ?? ''}
</p>
</div>
<div className='h-px w-full bg-[var(--border)]' />
{operations.map((op) => (
<div key={op.name}>
<div className='flex items-start gap-4 px-6 py-4'>
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
<p className='text-[14px] text-[var(--text-primary)] leading-snug tracking-[-0.02em]'>
{op.name}
</p>
{op.description && (
<p className='text-[12px] text-[var(--text-muted)] leading-[150%]'>
{op.description}
</p>
)}
</div>
</div>
<div className='h-px w-full bg-[var(--border)]' />
</div>
))}
</section>
)}
{/* FAQ - full width */}
<section aria-labelledby='faq-heading' className='px-6 py-10'>
<h2
id='faq-heading'
className='mb-8 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em]'
>
Frequently asked questions
</h2>
<LandingFAQ faqs={faqs} />
</section>
<div className='h-px w-full bg-[var(--border)]' />
{/* Related integrations - horizontal cards with vertical dividers (blog featured pattern) */}
{relatedIntegrations.length > 0 && (
<>
<nav aria-label='Related integrations' className='flex flex-col sm:flex-row'>
{relatedIntegrations.slice(0, 4).map((rel) => (
<Link
key={rel.slug}
href={`/integrations/${rel.slug}`}
className='group flex flex-1 flex-col gap-4 border-[var(--border)] border-t p-6 transition-colors first:border-t-0 hover:bg-[var(--surface-hover)] sm:border-t-0 sm:border-l sm:first:border-l-0'
>
<IntegrationIcon
bgColor={rel.bgColor}
name={rel.name}
Icon={blockTypeToIconMap[rel.type]}
as='span'
className='size-10 rounded-xl border border-[var(--border-1)]'
aria-hidden='true'
/>
<div className='flex flex-col gap-2'>
<h3 className='text-[var(--text-primary)] text-lg leading-tight tracking-[-0.01em]'>
{rel.name}
</h3>
<p className='line-clamp-2 text-[var(--text-muted)] text-sm leading-[150%]'>
{rel.description}
</p>
</div>
</Link>
))}
</nav>
<div className='h-px w-full bg-[var(--border)]' />
</>
)}
{/* Bottom CTA */}
<section aria-labelledby='cta-heading' className='px-6 py-16 text-center'>
<div className='mx-auto mb-6 flex items-center justify-center gap-3'>
<Image
src='/brandbook/logo/small.png'
alt='Sim'
width={56}
height={56}
className='shrink-0 rounded-xl'
unoptimized
/>
<div className='flex items-center gap-2'>
<span className='h-px w-5 bg-[var(--border-1)]' aria-hidden='true' />
<span
className='flex size-7 items-center justify-center rounded-full border border-[var(--border-1)]'
aria-hidden='true'
>
<svg
className='size-3.5 text-[var(--text-muted)]'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth={2}
strokeLinecap='round'
>
<path d='M5 12h14' />
<path d='M12 5v14' />
</svg>
</span>
<span className='h-px w-5 bg-[var(--border-1)]' aria-hidden='true' />
</div>
<IntegrationIcon
bgColor={bgColor}
name={name}
Icon={IconComponent}
className='size-14 rounded-xl'
iconClassName='size-7'
fallbackClassName='text-[22px]'
aria-hidden='true'
/>
</div>
<h2
id='cta-heading'
className='mb-3 text-[28px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] sm:text-[34px]'
>
Start automating {name} today
</h2>
<p className='mx-auto mb-8 max-w-[480px] text-[var(--text-body)] text-base leading-[150%] tracking-[0.02em]'>
Build your first AI agent with {name} in minutes. Connect to every tool your team uses.
Free to start, no credit card required.
</p>
<IntegrationCtaButton label='Build for free'>Build for free</IntegrationCtaButton>
</section>
</div>
{/* Closing full-width divider */}
<div className='-mt-px h-px w-full bg-[var(--border)]' />
</section>
)
}
@@ -0,0 +1,10 @@
import type { ReactNode } from 'react'
/**
* Integrations route segment. The shared landing layout owns the chrome (navbar,
* footer, site-wide JSON-LD, scroll port); this layout only provides the
* `<main>` landmark. Pages emit their own page-specific JSON-LD.
*/
export default function IntegrationsLayout({ children }: { children: ReactNode }) {
return <main id='main-content'>{children}</main>
}
@@ -0,0 +1,30 @@
import integrationsJson from '@/lib/integrations/integrations.json'
import type { Integration } from '@/lib/integrations/types'
import { createLandingOgImage } from '@/app/(landing)/og-utils'
export const contentType = 'image/png'
export const size = {
width: 1200,
height: 630,
}
/** Raw catalog JSON, not the barrel - keeps `@/blocks/registry` out of the OG bundle. */
const integrations = integrationsJson.integrations as readonly Integration[]
const TOTAL_TOOL_COUNT = integrations.reduce((sum, i) => sum + i.operationCount, 0)
const OAUTH_COUNT = integrations.filter((i) => i.authType === 'oauth').length
const TRIGGER_INTEGRATION_COUNT = integrations.filter((i) => i.triggerCount > 0).length
export default async function Image() {
return createLandingOgImage({
eyebrow: 'Sim integrations directory',
title: 'Integrations',
subtitle: `Connect ${integrations.length} apps and services to AI agents in Sim's workflow builder, visually, conversationally, or with code.`,
pills: [
`${integrations.length} integrations`,
`${TOTAL_TOOL_COUNT}+ tools`,
`${OAUTH_COUNT} OAuth apps`,
`${TRIGGER_INTEGRATION_COUNT} with real-time triggers`,
],
domainLabel: 'sim.ai/integrations',
})
}
@@ -0,0 +1,253 @@
import type { Metadata } from 'next'
import type { SearchParams } from 'nuqs/server'
import { SITE_URL } from '@/lib/core/utils/urls'
import {
blockTypeToIconMap,
type FAQItem,
INTEGRATIONS,
type Integration,
POPULAR_WORKFLOWS,
} from '@/lib/integrations'
import { withFilteredNoindex } from '@/lib/landing/seo'
import { JsonLd } from '@/app/(landing)/components/json-ld'
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
import { IntegrationCard } from '@/app/(landing)/integrations/components/integration-card'
import { IntegrationGrid } from '@/app/(landing)/integrations/components/integration-grid'
import { RequestIntegrationModal } from '@/app/(landing)/integrations/components/request-integration-modal'
import { integrationsSearchParamsCache } from '@/app/(landing)/integrations/search-params'
const allIntegrations = INTEGRATIONS
const INTEGRATION_COUNT = allIntegrations.length
const OAUTH_COUNT = allIntegrations.filter((i) => i.authType === 'oauth').length
const TRIGGER_INTEGRATION_COUNT = allIntegrations.filter((i) => i.triggerCount > 0).length
const TOTAL_TOOL_COUNT = allIntegrations.reduce((sum, i) => sum + i.operationCount, 0)
/**
* Catalog-level FAQ. Questions that read the same for every integration live
* here exactly once instead of repeating across all per-integration pages.
*/
const CATALOG_FAQS: FAQItem[] = [
{
question: 'How do integrations work in Sim?',
answer: `Each integration is a block you drag onto Sim's workflow builder. Together, Sim's ${INTEGRATION_COUNT} integrations expose ${TOTAL_TOOL_COUNT}+ tools that AI agents can call. ${OAUTH_COUNT} connect with one-click OAuth, and the rest use an API key or no authentication at all. Wire blocks together, add an AI agent block for reasoning, and run.`,
},
{
question: 'Are Sim integrations free to use?',
answer: `Yes. Sim's free plan includes every integration in the library, all ${INTEGRATION_COUNT} of them, with no credit card required. Create an account at sim.ai and start building.`,
},
{
question: 'Can an AI agent decide when to use an integration?',
answer: `Yes. This is the core of Sim. You give an agent access to integration tools and describe the goal in plain language; the agent decides which tools to call, in what order, and how to handle the results. Automations adapt to context instead of breaking when inputs change.`,
},
{
question: 'Can external events trigger my agents automatically?',
answer: `Yes. ${TRIGGER_INTEGRATION_COUNT} Sim integrations include real-time webhook triggers. Add a trigger block to your agent, copy its webhook URL into the external service, and every matching event starts your agent instantly, no polling, no delay.`,
},
{
question: 'How many integrations does Sim support?',
answer: `Sim supports ${INTEGRATION_COUNT} integrations across messaging, CRMs, databases, developer tools, AI providers, and more, and the catalog grows continually. If a tool you need is missing, request it below and we'll prioritize it.`,
},
]
/**
* Unique integration names that appear in popular workflow pairs.
* Used for metadata keywords so they stay in sync automatically.
*/
const TOP_NAMES = [...new Set(POPULAR_WORKFLOWS.flatMap((p) => [p.from, p.to]))].slice(0, 6)
const baseUrl = SITE_URL
const INTEGRATIONS_BREADCRUMB_JSON_LD = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl },
{
'@type': 'ListItem',
position: 2,
name: 'Integrations',
item: `${baseUrl}/integrations`,
},
],
}
/** Curated featured integrations - high-recognition services shown as cards. */
const FEATURED_SLUGS = ['slack', 'notion', 'github', 'gmail'] as const
const bySlug = new Map(allIntegrations.map((i) => [i.slug, i]))
const featured = FEATURED_SLUGS.map((s) => bySlug.get(s)).filter(
(i): i is Integration => i !== undefined
)
/**
* `q`/`category` render a genuinely different server-rendered list (see
* search-params.ts), so filtered URLs are noindexed rather than
* self-canonicalized — keeps the single indexable URL as the bare catalog
* page instead of asking Google to index every filter permutation.
*/
export async function generateMetadata({
searchParams,
}: {
searchParams: Promise<SearchParams>
}): Promise<Metadata> {
const { q, category } = await integrationsSearchParamsCache.parse(searchParams)
const isFiltered = Boolean(q || category)
return withFilteredNoindex(
{
title: 'Integrations',
description: `Connect ${INTEGRATION_COUNT}+ apps and services in Sim's AI workspace. Build agents that automate real work with ${TOP_NAMES.join(', ')}, and more.`,
keywords: [
'AI workspace integrations',
'AI agent integrations',
'AI agent builder integrations',
...TOP_NAMES.flatMap((n) => [`${n} integration`, `${n} automation`]),
...allIntegrations.slice(0, 20).map((i) => `${i.name} automation`),
],
// og:image/twitter:image come from the sibling opengraph-image.tsx -
// Next serves it at a hash-suffixed URL, so hardcoding it here 404s.
openGraph: {
title: 'Integrations | Sim AI Workspace',
description: `Connect ${INTEGRATION_COUNT}+ apps in Sim's AI workspace. Build agents that link ${TOP_NAMES.join(', ')}, and every tool your team uses.`,
url: `${baseUrl}/integrations`,
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: 'Integrations | Sim',
description: `Connect ${INTEGRATION_COUNT}+ apps in Sim's AI workspace.`,
},
alternates: { canonical: `${baseUrl}/integrations` },
},
isFiltered
)
}
export default async function IntegrationsPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
await integrationsSearchParamsCache.parse(searchParams)
const itemListJsonLd = {
'@context': 'https://schema.org',
'@type': 'ItemList',
name: 'Sim AI Workflow Integrations',
description: `Complete list of ${INTEGRATION_COUNT}+ integrations available in Sim's AI workspace for building and deploying AI agents.`,
url: `${baseUrl}/integrations`,
numberOfItems: INTEGRATION_COUNT,
itemListElement: allIntegrations.map((integration, index) => ({
'@type': 'ListItem',
position: index + 1,
item: {
'@type': 'SoftwareApplication',
name: integration.name,
description: integration.description,
url: `${baseUrl}/integrations/${integration.slug}`,
applicationCategory: 'BusinessApplication',
featureList: integration.operations.map((o) => o.name),
},
})),
}
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: CATALOG_FAQS.map(({ question, answer }) => ({
'@type': 'Question',
name: question,
acceptedAnswer: { '@type': 'Answer', text: answer },
})),
}
return (
<section className='bg-[var(--bg)]'>
<JsonLd data={INTEGRATIONS_BREADCRUMB_JSON_LD} />
<JsonLd data={itemListJsonLd} />
<JsonLd data={faqJsonLd} />
{/* Hero */}
<div className='mx-auto w-full max-w-[1460px] px-20 pt-[112px] max-sm:px-5 max-sm:pt-20 max-lg:px-8'>
<div className='flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between'>
<h1
id='integrations-heading'
className='text-balance text-[28px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[40px]'
>
Integrations
</h1>
<p className='text-[var(--text-muted)] text-sm leading-[150%] tracking-[0.02em] lg:text-base'>
Connect every tool your team uses. Build agents that automate real work across{' '}
{INTEGRATION_COUNT} apps and services.
</p>
</div>
</div>
{/* Full-width divider */}
<div className='mt-8 h-px w-full bg-[var(--border)]' />
{/* Border-railed content */}
<div className='mx-auto w-full max-w-[1460px]'>
<div className='mx-20 border-[var(--border)] border-x max-sm:mx-5 max-lg:mx-8'>
{/* Featured integrations - top */}
{featured.length > 0 && (
<>
<nav aria-label='Featured integrations' className='flex flex-col sm:flex-row'>
{featured.map((integration) => (
<IntegrationCard
key={integration.type}
integration={integration}
IconComponent={blockTypeToIconMap[integration.type]}
/>
))}
</nav>
<div className='h-px w-full bg-[var(--border)]' />
</>
)}
{/* All Integrations - search, filters, rows */}
<section aria-labelledby='all-integrations-heading'>
<div className='px-6 pt-10 pb-4'>
<h2
id='all-integrations-heading'
className='mb-2 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[24px]'
>
All Integrations
</h2>
</div>
<IntegrationGrid integrations={allIntegrations} />
</section>
{/* FAQ */}
<section aria-labelledby='integrations-faq-heading' className='px-6 py-10'>
<h2
id='integrations-faq-heading'
className='mb-8 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em]'
>
Frequently asked questions
</h2>
<LandingFAQ faqs={CATALOG_FAQS} />
</section>
<div className='h-px w-full bg-[var(--border)]' />
{/* Integration request */}
<div className='flex flex-col items-start gap-3 p-6 sm:flex-row sm:items-center sm:justify-between'>
<div>
<p className='text-[15px] text-[var(--text-primary)] tracking-[-0.02em]'>
Don&apos;t see the integration you need?
</p>
<p className='mt-0.5 text-[var(--text-muted)] text-xs uppercase tracking-[0.1em]'>
Let us know and we&apos;ll prioritize it.
</p>
</div>
<RequestIntegrationModal />
</div>
</div>
</div>
{/* Closing full-width divider */}
<div className='-mt-px h-px w-full bg-[var(--border)]' />
</section>
)
}