diff --git a/apps/docs/content/docs/knowledgebase/connectors.mdx b/apps/docs/content/docs/knowledgebase/connectors.mdx
index 31110c79c1c..e3624676668 100644
--- a/apps/docs/content/docs/knowledgebase/connectors.mdx
+++ b/apps/docs/content/docs/knowledgebase/connectors.mdx
@@ -16,11 +16,11 @@ Connectors continuously sync documents from external services into your knowledg
-Sim ships with 64 built-in connectors:
+Sim ships with 66 built-in connectors:
| Category | Connectors |
|----------|-----------|
-| **Productivity** | Notion, Confluence, Asana, Linear, Jira, Jira Service Management, Monday, Trello, ClickUp, Google Calendar, Google Sheets, Google Forms, Microsoft Excel, Typeform |
+| **Productivity** | Coda, Notion, Confluence, Asana, Linear, Jira, Jira Service Management, Monday, Trello, ClickUp, Google Calendar, Google Sheets, Google Forms, Microsoft Excel, Typeform |
| **Cloud Storage** | Google Drive, Dropbox, OneDrive, SharePoint, Box, Amazon S3, SFTP |
| **Documents** | Google Docs, Google Slides, Mintlify, WordPress, Webflow, DocuSign |
| **Development** | GitHub, GitLab, Bitbucket, Azure DevOps, Sentry |
@@ -31,7 +31,7 @@ Sim ships with 64 built-in connectors:
| **Incident Management** | incident.io, Rootly, PagerDuty |
| **Data** | Airtable, Databricks |
| **Note-taking** | Obsidian |
-| **Meetings** | Zoom, Google Meet, Gong, Grain, Granola, Fathom, Fireflies |
+| **Meetings** | Zoom, Google Meet, Gong, Grain, Granola, Fathom, Fireflies, Circleback |
| **Recruiting** | Greenhouse, Ashby |
| **HR** | Workday Help |
| **Compliance** | Google Vault |
@@ -52,6 +52,7 @@ Other connectors use **API keys** or **personal access tokens** instead. The set
| Connector | Where to get the key |
|-----------|---------------------|
| **Obsidian** | Install the [Local REST API](https://github.com/coddingtonbear/obsidian-local-rest-api) plugin, then copy the key from its settings |
+| **Coda** | API token from Account settings → API connections, saved as a Coda service account credential |
| **Fireflies** | Generate from the Integrations page in your Fireflies account |
| **Typeform** | Personal access token from your Typeform account settings |
| **Azure DevOps** | Personal access token with Wiki (Read), Work Items (Read), and Code (Read) scopes |
diff --git a/apps/docs/content/docs/search/coda.mdx b/apps/docs/content/docs/search/coda.mdx
new file mode 100644
index 00000000000..c2a276f7cf5
--- /dev/null
+++ b/apps/docs/content/docs/search/coda.mdx
@@ -0,0 +1,66 @@
+---
+title: Coda
+description: Connect Coda documents while preserving each teammate's access
+---
+
+import { Step, Steps } from 'fumadocs-ui/components/steps'
+import { Image } from '@/components/ui/image'
+
+An organization admin connects Coda once. Teammates search using their verified Sim email address; they do not need a personal Coda connection. Their email must match the address Coda reports.
+
+## Connect Coda
+
+
+
+
+### Create an API token
+
+Sign in to Coda with a dedicated integration account that can read the documents and their sharing permissions. Open **Account settings → API connections → Generate API token**, name the token, and copy it. Coda's current app may be labeled **Superhuman Docs**.
+
+Use a standard REST API token. Do not select the MCP restriction.
+
+
+
+
+
+
+### Add the source
+
+In Sim, open **Settings → Sources → Add source → Coda**. Add or select a Coda service account credential and enter the token when prompted.
+
+| Field | What to enter |
+|---|---|
+| Documents | Select documents, or switch to **Document IDs** and enter up to 100 IDs separated by commas. For `https://coda.io/d/_dAbCDeFGH`, the ID is `AbCDeFGH`. |
+| Enterprise organization ID | Leave blank for ordinary access. For organization-wide indexing, enter the ID from Coda's [List organizations API](https://coda.io/developers/apis/admin/v1#operation/listOrganizations) and use an Enterprise organization administrator's token. Workspace admin access alone is insufficient. |
+
+The picker shows accessible documents the token owner has opened. Use IDs for other accessible documents. With no selection, Sim discovers the token owner's opened documents, or the organization's documents in Enterprise mode. Use explicit IDs when deleted documents must reliably be removed from the index; discovery listings alone are not authoritative for deletion.
+
+
+
+
+### Sync and verify
+
+Select **Connect & Sync**. Open the source's **Documents** and **Sync history** to check indexing. Content and permission changes appear after synchronization.
+
+To rotate the token, add the replacement credential, update the source, and verify a sync before revoking the old token.
+
+
+
+
+## Content and access
+
+Ordinary connections index visible canvas text and base-table rows. Enterprise connections index the Admin API's plaintext page exports; tables are included only as represented in those exports. Attachments, comments, images, and remote embeds are not extracted.
+
+| Permission | Ordinary connection | Enterprise connection |
+|---|---|---|
+| Owner or direct email share | Matching verified Sim email | Matching active organization user |
+| Group, workspace, or domain share | Not expanded | Resolved through the Admin API |
+| Anyone with the link | Does not grant Search access | Does not grant Search access |
+
+Enterprise mode excludes deactivated and deleted users. Direct-share guests absent from the organization directory are excluded; guests explicitly listed in a group or workspace may receive that membership's access. Ordinary connections cannot check Coda organization deactivation, so manage departing users' Sim membership too.
+
+Sim refreshes permissions even when content is unchanged. Unverified permissions never become broad access, and Sim admin status does not override Coda sharing. For setup failures or missing results, check the token's access, Enterprise admin role if applicable, the teammate's verified email, and **Sync history**.
+
+In a regular knowledge base, **Workspace access** intentionally shares all indexed content with that Sim workspace and does not use the Enterprise organization ID. The permissions above apply to organization Search and **Admin access** sources.
+
+See Coda's [public API](https://coda.io/developers/apis/v1) and [Enterprise Admin API](https://coda.io/developers/apis/admin/v1).
diff --git a/apps/docs/content/docs/search/connect-your-account.mdx b/apps/docs/content/docs/search/connect-your-account.mdx
index b82cdf817a2..6d741241fe1 100644
--- a/apps/docs/content/docs/search/connect-your-account.mdx
+++ b/apps/docs/content/docs/search/connect-your-account.mdx
@@ -60,6 +60,7 @@ For a source configured inside a workspace, join that workspace and connect thro
| GitHub App installation | Connect GitHub once for this Sim organization. The App handles indexing; your account establishes which repositories you may search. |
| Confluence service account | Connect Confluence to verify your identity; the service account handles the crawl. |
| Google Workspace service account (Gmail, Calendar, Drive) | No personal connection is needed for that source. Your verified Sim email identifies your mailbox and calendar view, or is matched to Drive permissions. |
+| [Coda](/search/coda) | No personal connection is needed. Your verified Sim email must match a supported Coda sharing grant. |
| GitLab | No personal connection is needed. For an administrator token, your verified Sim email must match your confirmed primary GitLab email. For a non-admin token, it must match the uploaded user mapping and have a project grant. |
Gmail, Calendar, and Drive are separate Search sources. Connecting one Google service does not connect all of them. Central Google and GitLab sources remain searchable without a personal account connection.
diff --git a/apps/docs/content/docs/search/meta.json b/apps/docs/content/docs/search/meta.json
index 473704f2d45..8a83d115762 100644
--- a/apps/docs/content/docs/search/meta.json
+++ b/apps/docs/content/docs/search/meta.json
@@ -3,6 +3,7 @@
"pages": [
"connect-your-account",
"mcp",
+ "coda",
"confluence",
"github",
"gitlab",
diff --git a/apps/docs/public/static/search/coda-api-token.jpg b/apps/docs/public/static/search/coda-api-token.jpg
new file mode 100644
index 00000000000..09aa99e08eb
Binary files /dev/null and b/apps/docs/public/static/search/coda-api-token.jpg differ
diff --git a/apps/sim/connectors/coda/README.md b/apps/sim/connectors/coda/README.md
new file mode 100644
index 00000000000..1b2ae294e9b
--- /dev/null
+++ b/apps/sim/connectors/coda/README.md
@@ -0,0 +1,79 @@
+# Coda connector decisions and verification
+
+## Precedent and authentication
+
+| Reference | Verified behavior | What it establishes for Sim |
+| --- | --- | --- |
+| [Glean Coda search](https://www.glean.com/connectors/coda) | Native document/page indexing with query-time source permissions. | The same access-control objective. The public page does not specify the crawler's credentials, endpoints, directory expansion, or deletion strategy; exact implementation parity is not established. |
+| [Glean Coda agent service credential](https://docs.glean.com/administration/agent-identity/coda) | A dedicated Coda user and an MCP-restricted token for agent tools. | Dedicated identity and credential rotation are useful operational precedent. This is a separate MCP integration, not the REST indexing setup; its MCP token restriction must not be copied into Sim's connector instructions. |
+| [Onyx Coda connector](https://github.com/onyx-dot-app/onyx/blob/5fe6573c3c155e1a75b51de32d4988ee6c82164e/backend/onyx/connectors/coda/connector.py) | A bearer token, optional workspace scope, page content, and table rows through the public REST API. | Direct precedent for ordinary content ingestion. Sim uses bounded deferred hydration rather than collecting the entire source before indexing. |
+
+Onyx's [test setup](https://github.com/onyx-dot-app/onyx/blob/5fe6573c3c155e1a75b51de32d4988ee6c82164e/backend/tests/daily/connectors/coda/README.md) also uses an API token generated in account settings. Its reviewed Coda implementation has no source ACL or directory calls, and its [permission-sync registry](https://github.com/onyx-dot-app/onyx/blob/5fe6573c3c155e1a75b51de32d4988ee6c82164e/backend/ee/onyx/external_permissions/sync_params.py) has no Coda entry. It therefore does not establish Enterprise permission parity. Coda's own API documentation is the authority for Sim's Enterprise path.
+
+This connector reuses Sim's existing `coda-service-account` token credential and its selector. The metadata's `oauth` discriminator means “use a stored credential” in the shared connector framework. The actual credential is an API token, with no OAuth authorization or refresh flow. Personal credential-group enrollment is not exposed because that flow currently requires OAuth. Admin indexing matches provider-reported grants to verified Sim emails instead.
+
+## API contract audit
+
+All connector requests are GET requests authenticated with `Authorization: Bearer`. The following operation IDs were checked against the OpenAPI specifications embedded in Coda's [public reference](https://coda.io/developers/apis/v1) and [Admin reference](https://coda.io/developers/apis/admin/v1), including query parameters, response fields, pagination, and allowed formats. None of these operations is deprecated.
+
+| API | Operations used |
+| --- | --- |
+| Public identity and discovery | `whoami`, `listDocs`, `getDoc` |
+| Public content | `listPages`, `getPage`, `listPageContent`, `listTables`, `getTable`, `listColumns`, `listRows` |
+| Public permissions | `getPermissions` |
+| Admin discovery | `getOrganization`, `listDocs` with `docIds` or `docAvailabilityState` and `fetchPermissionsMode=none` |
+| Admin content and permissions | `listPagesV2`, `getPageV2` with `outputFormat=LossyPlainText`, `listDocPermissionsV2` |
+| Admin directory | `listOrgUsers`, `listGroups`, `listGroupMembers`, `listWorkspaces`, `listWorkspaceUsers` |
+
+The Admin API requires an [Enterprise organization administrator](https://help.superhuman.com/hc/en-us/articles/46210125237901-Intro-to-Superhuman-Docs-Admin-API). Ordinary workspace admin access does not satisfy that requirement. All network calls use the shared secure fetch/retry path and fixed API origins; the connector uses no browser session, private endpoint, credential impersonation, or provider permission mutation. Synthetic directory groups are Sim's internal representation of documented grants, not invented Coda API objects. Size and crawl limits below are Sim safeguards, not claimed Coda service limits.
+
+## Access tradeoffs
+
+- Without an organization ID, only ownership and direct email grants establish individual access. Workspace, domain, group, and unknown principals are not expanded. Link possession never grants Search access. The public API does not provide organization account status; this mode cannot independently detect organization-level deactivation while a direct grant remains. Sim membership lifecycle must also be managed.
+- With an Enterprise organization ID, the Admin API supplies organization discovery and directory membership. Direct users and domains use synthetic groups so deactivated/deleted organization users cannot resolve grants. External direct-share guests absent from the organization directory are conservatively excluded. Explicit group/workspace guests remain eligible unless the directory reports them inactive.
+- Opaque group/workspace IDs are hex encoded before shared case-folding. Tenant IDs remain source-exact. This avoids merging case-sensitive Coda IDs.
+- Every listed document's complete ACL is read on every sync, independently of content hashes. Partial/failed ACLs are omitted so the shared engine fails closed. Directory snapshots are collected under the shared directory lease and never reported complete after a truncated response.
+- Permission freshness, query authorization, and directory persistence remain in the existing shared engines. Workspace access mode deliberately grants workspace-wide access and is separate from organization Search.
+
+## Content and pagination tradeoffs
+
+The [public API](https://coda.io/developers/apis/v1) exposes canvas plaintext and base-table rows; the [Admin API](https://coda.io/developers/apis/admin/v1) exposes `LossyPlainText` page exports. Admin exports are not equivalent to independently fetching every table. Hidden/embed/synced pages are excluded on the public path; the Admin API does not expose those public page-type flags. Neither path extracts arbitrary attachments or remote embeds.
+
+Admin document reads use the current workspace-qualified endpoints, resolving the workspace with the documented organization `docIds` filter. Metadata queries request `fetchPermissionsMode=none`; permissions come only from the complete paginated ACL endpoint.
+
+Listing defers content and retains only the current parent document metadata in the per-run context, avoiding one redundant parent fetch per child. Resource content and permissions are still read from the provider; ACLs never use that metadata cache. Discovery fetches ten parent documents at a time. Compound cursors persist that bounded queue, the current document, child phase, and provider token for durable replay, under the shared 512 KiB cursor limit. Only `pageToken` is sent on continuation requests, as Coda requires. No response URL or redirect receives the credential. Responses, text, list lengths, ACLs, directory entries, and pagination loops are bounded and fail rather than silently truncating. Responses are limited to 4 MiB, indexed text to 12 MiB, and hydration concurrency to two. The stubs advertise the text bound to the shared byte-budget scheduler. Directory entries are limited to 100,000, email lengths to 254, and document permissions to 5,000. Domain memberships are indexed once instead of scanning every user for every domain.
+
+Hashes use the parent document revision because table-list references do not guarantee a row-sensitive timestamp. A document edit invalidates all its children, trading extra reads for correctness. Explicit full resyncs also refresh rendered dependencies. When the Admin API omits its optional revision timestamp, the shared durable sync-generation ID forces one refresh per crawl, following the Slack/GitLab pattern. It remains stable across retries within that generation.
+
+Discovery order is mutable and the public list omits never-opened documents. Discovery is therefore non-authoritative for deletion reconciliation. Explicit document IDs provide authoritative scope; confirmed 404/410 documents reconcile, while 403s remain errors. Up to 100 explicit IDs are supported per source. Setup probes one document and one ACL page to keep request latency bounded; sync checks the remaining scope.
+
+## Reproducible checks
+
+From `apps/sim`:
+
+```sh
+bunx vitest run connectors/coda/coda.test.ts connectors/coda/permissions.test.ts lib/selectors/server/providers/coda.test.ts lib/credentials/token-service-accounts/validators/coda.test.ts lib/selectors/manifest.test.ts
+CODA_CONNECTOR_LIVE_TOKEN_FILE=/path/to/token bunx vitest run connectors/coda/coda.live.test.ts
+```
+
+The provider test creates and deletes its own document. `CODA_CONNECTOR_LIVE_ORGANIZATION_ID` enables additional Enterprise export/ACL/directory checks against that fixture when the token belongs to an Enterprise organization. Set `CODA_CONNECTOR_LIVE_FIXTURE_FILE=/path/to/fixture.json` to retain it for the application test, then from the repository root:
+
+```sh
+CODA_CONNECTOR_LIVE_TOKEN_FILE=/path/to/token \
+CODA_CONNECTOR_LIVE_FIXTURE_FILE=/path/to/fixture.json \
+CODA_CONNECTOR_LIVE_SECOND_EMAIL=second-user@example.com \
+bun scripts/test-knowledge-acls.ts coda-live.integration.ts
+```
+
+The application test uses disposable PostgreSQL/Redis, real credentials, Coda, source creation, ingestion, storage, and authorization. Set `CODA_CONNECTOR_LIVE_SCOPE=organization` to exercise organization credential creation and organization Search instead of a workspace knowledge base. Only embeddings are deterministic substitutes. It changes only the retained fixture's share, with notifications suppressed, and removes that share afterward. `CODA_CONNECTOR_LIVE_ALLOW_SHARING=false` explicitly skips the live grant/revoke cases when source policy prohibits sharing. `CODA_CONNECTOR_LIVE_UI_FIXTURE_FILE` optionally retains isolated rows and writes a temporary signed session for browser verification; use only with the runner's disposable-database retention options and remove both afterward.
+
+## Verification coverage
+
+- Focused connector, selector, credential, navigation, configuration, and integration-availability tests passed. App and deployment-config type checks, Biome, and the strict API-contract audit passed.
+- Live provider tests cover page/table hydration, page edits, and row edits. Coda's real initialization `409` led to an explicit retry path.
+- Real application setup and ingestion passed in both workspace and organization scope (the live sharing cases require a workspace that allows the intended cross-domain share). Organization scope creates the token credential through the authorized application use case. Owner search/chunks were allowed; another verified user, unverified owner, and workspace API key were denied. Embeddings were substituted, so external embedding-provider behavior was not tested.
+- Sim's browser showed the Coda source Active, both indexed documents, the live document picker, the saved organization credential, and preserved selection across connection/input modes. Browser checks caught and fixed missing Search URL registration, scope loss when switching modes, and deployment metadata rejecting Coda's token credential. Coda's official browser app showed the fixture content.
+- Live sharing to the requested second account was rejected by the source policy: “Cross domain sharing is prohibited.” Grant/revoke tests remain unverified live, with unit coverage for ACL changes and incomplete responses.
+- The available test account returned no Enterprise organizations; the official UI offers sales-assisted Enterprise setup rather than a self-service trial. Admin API behavior is checked against published schemas and mocked responses; organization-wide crawling, deactivation, and group/workspace/domain permission parity still require an Enterprise tenant test before being considered release-verified.
+
+These remaining provider prerequisites prevent claiming complete live verification.
diff --git a/apps/sim/connectors/coda/client.ts b/apps/sim/connectors/coda/client.ts
new file mode 100644
index 00000000000..b6a16206ebd
--- /dev/null
+++ b/apps/sim/connectors/coda/client.ts
@@ -0,0 +1,129 @@
+import { z } from 'zod'
+import { fetchWithRetry } from '@/lib/knowledge/documents/secure-fetch.server'
+import { isRetryableError, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
+import { ConnectorSourceError } from '@/connectors/source-error'
+import { readBodyWithLimit } from '@/connectors/utils'
+import { buildCodaUrl, codaHeaders } from '@/tools/coda/utils'
+
+export const CODA_RESPONSE_BYTES = 4 * 1024 * 1024
+export const codaIdSchema = z.string().regex(/^[a-zA-Z0-9_-]{1,512}$/)
+export const codaEmailSchema = z.string().email().max(254)
+export const codaPageTokenSchema = z.string().min(1).max(4096)
+const timestampSchema = z.string().datetime({ offset: true })
+const sourceUrlSchema = z.string().url().max(4096)
+
+export const codaDocSchema = z.object({
+ id: codaIdSchema,
+ name: z.string().max(4096),
+ browserLink: sourceUrlSchema,
+ updatedAt: timestampSchema.optional(),
+ owner: codaEmailSchema.optional(),
+ workspaceId: codaIdSchema.optional(),
+ isDeleted: z.boolean().optional(),
+ keyAccessRevoked: z.boolean().optional(),
+})
+
+export const codaAdminDocSchema = codaDocSchema.required({ workspaceId: true })
+
+export const codaResourceSchema = z.object({
+ id: codaIdSchema,
+ name: z.string().max(4096),
+ browserLink: sourceUrlSchema,
+})
+
+export const codaPageSchema = codaResourceSchema.extend({
+ contentType: z.string(),
+ isHidden: z.boolean(),
+ isEffectivelyHidden: z.boolean(),
+})
+
+export const codaTableSchema = codaResourceSchema.extend({ tableType: z.string() })
+export type CodaDoc = z.infer
+export type CodaResource = z.infer
+
+export function codaListSchema(item: T) {
+ return z.object({
+ items: z.array(item).max(1000),
+ nextPageToken: codaPageTokenSchema.optional(),
+ })
+}
+
+/** Provider URLs are constructed locally; response links never receive the credential. */
+export async function codaJson(
+ token: string,
+ path: string,
+ schema: T,
+ query?: Record,
+ validating = false,
+ admin = false
+): Promise> {
+ const response = await fetchWithRetry(
+ buildCodaUrl(path, query, admin ? 'admin' : 'public'),
+ { headers: codaHeaders(token), redirect: 'error' },
+ {
+ ...(validating ? VALIDATE_RETRY_OPTIONS : {}),
+ /** A newly created Coda doc returns 409 until its API snapshot is ready. */
+ fetcher: async (input, init, transport) => {
+ const response = await transport(input, init)
+ if (response.status === 409) {
+ await response.body?.cancel()
+ throw new ConnectorSourceError(
+ 'Coda document is still initializing',
+ 409,
+ 'provider_unavailable'
+ )
+ }
+ return response
+ },
+ retryCondition: (error) =>
+ (error instanceof ConnectorSourceError && error.status === 409) || isRetryableError(error),
+ }
+ )
+ if (!response.ok) {
+ await response.body?.cancel()
+ throw new ConnectorSourceError(
+ `Coda request failed (${response.status})`,
+ response.status,
+ response.status === 401 || response.status === 403 ? 'authorization' : undefined
+ )
+ }
+ const body = await readBodyWithLimit(response, CODA_RESPONSE_BYTES)
+ if (!body) throw new Error('Coda response exceeded the 4MB limit')
+ let data: unknown
+ try {
+ data = JSON.parse(body.toString('utf8'))
+ } catch {
+ throw new Error('Coda returned invalid JSON')
+ }
+ const parsed = schema.safeParse(data)
+ if (!parsed.success) throw new Error('Coda returned an invalid response')
+ return parsed.data
+}
+
+/** Drains bounded collections without retaining their response bodies or following nextPageLink. */
+export async function* codaPages(
+ token: string,
+ path: string,
+ schema: T,
+ query?: Record,
+ admin = false
+) {
+ let pageToken: string | undefined
+ const seen = new Set()
+ for (let page = 0; page < 1000; page++) {
+ const result = await codaJson(
+ token,
+ path,
+ codaListSchema(schema),
+ { limit: 100, ...query, pageToken },
+ false,
+ admin
+ )
+ yield result.items
+ pageToken = result.nextPageToken
+ if (!pageToken) return
+ if (seen.has(pageToken)) throw new Error('Coda repeated a pagination token')
+ seen.add(pageToken)
+ }
+ throw new Error('Coda exceeded the 1000-page safety limit')
+}
diff --git a/apps/sim/connectors/coda/coda.live.test.ts b/apps/sim/connectors/coda/coda.live.test.ts
new file mode 100644
index 00000000000..a2c9d8aa881
--- /dev/null
+++ b/apps/sim/connectors/coda/coda.live.test.ts
@@ -0,0 +1,225 @@
+/**
+ * Opt-in provider tests. Only documents created here are mutated or deleted.
+ * Set CODA_CONNECTOR_LIVE_TOKEN_FILE to a local token file. Optionally set
+ * CODA_CONNECTOR_LIVE_FIXTURE_FILE to retain the fixture for UI and access tests.
+ * @vitest-environment node
+ */
+import { readFileSync, writeFileSync } from 'node:fs'
+import { sleep } from '@sim/utils/helpers'
+import { generateShortId } from '@sim/utils/id'
+import { afterAll, describe, expect, it } from 'vitest'
+import { z } from 'zod'
+import { fetchWithRetry } from '@/lib/knowledge/documents/secure-fetch.server'
+import { codaConnector } from '@/connectors/coda/coda'
+import type { ExternalDocument } from '@/connectors/types'
+import { buildCodaUrl, codaDocPath, codaHeaders } from '@/tools/coda/utils'
+
+const tokenPath = process.env.CODA_CONNECTOR_LIVE_TOKEN_FILE
+const fixturePath = process.env.CODA_CONNECTOR_LIVE_FIXTURE_FILE
+const token = tokenPath ? readFileSync(tokenPath, 'utf8').trim() : ''
+const resourceSchema = z.object({ id: z.string(), browserLink: z.string() })
+const mutationSchema = z.object({ requestId: z.string() })
+
+async function request(
+ path: string,
+ schema: T,
+ method = 'GET',
+ body?: unknown
+) {
+ const response = await fetchWithRetry(buildCodaUrl(path), {
+ method,
+ headers: codaHeaders(token, body !== undefined),
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
+ redirect: 'error',
+ })
+ if (!response.ok) throw new Error(`Live Coda ${method} failed (${response.status})`)
+ return schema.parse(await response.json())
+}
+
+async function waitFor(read: () => Promise): Promise {
+ for (let attempt = 0; attempt < 60; attempt++) {
+ const value = await read()
+ if (value !== undefined) return value
+ await sleep(1000)
+ }
+ throw new Error('Coda did not converge within 60 seconds')
+}
+
+describe.skipIf(!token).sequential('Coda connector live', () => {
+ let docId = ''
+ let pageId = ''
+ let before: ExternalDocument[] = []
+ const marker = `SimConnector-${generateShortId()}`
+
+ async function list() {
+ const documents: ExternalDocument[] = []
+ let cursor: string | undefined
+ for (let count = 0; count < 10; count++) {
+ const result = await codaConnector.listDocuments(token, { docIds: [docId] }, cursor)
+ documents.push(...result.documents)
+ cursor = result.nextCursor
+ if (!result.hasMore) return documents
+ }
+ throw new Error('Unexpected fixture pagination')
+ }
+
+ afterAll(async () => {
+ if (docId && !fixturePath) {
+ await request(codaDocPath(docId), z.object({}), 'DELETE')
+ }
+ })
+
+ it('creates a disposable page and table, then lists and hydrates both through the connector', async () => {
+ const created = await request('/docs', resourceSchema, 'POST', {
+ title: `Sim Coda connector verification ${marker}`,
+ initialPage: {
+ name: 'Connector verification',
+ pageContent: {
+ type: 'canvas',
+ canvasContent: {
+ format: 'html',
+ content: `