Service auth specifics to fill: authorize/token/revoke URLs, OAuth scopes, host-normalization rule (if any), JWT audience, and the baseUrl/per-op host shape.
Fetch the official API docs (WebFetch/WebSearch the OpenAPI spec). Produce a single OpenAPI 3.x swagger.yml at the workspace root. Prisme's importer ignores unknown root files, but commit it — it is the source of truth for the registry and must be retrievable from git. For multi-API services, merge the APIs into one spec, tagging each operation with its API/base host. Keep operationIds stable; they become the registry operationNames.
Find the service's official logo (WebSearch). Always save it locally in <workspace>/assets/ and commit it to git FIRST — so the source is recoverable if the uploaded file is ever lost (e.g. the workspace it was uploaded to gets deleted, taking its /files/ with it). Then upload_file (public) and set index.yml photo: to the returned URL. An app cannot be published without a photo — do this before the first push. Apply the same assets/ rule to ANY binary the connector depends on (icons, sample payloads, fixtures) — keep the source in assets/ + git, never only in the platform's /files/.
Deploy runbook (back changes need only a push; front changes need a bundle upload first):
-
Secret refs are opaque. run: module: secrets, function: get returns a $secret: reference that decrypts ONLY when interpolated inside a fetch (url/headers/body). You CANNOT read a field off it (.accessToken) in DSUL, and Custom Code cannot decrypt it. → store per-field secrets, and for the cron OAuth path exchange the refresh token on the spot (you get a plaintext access token back).
-
RBAC on workspace secrets. A SecureSecret scope: workspace can be written only by Owner/Editor/SuperAdmin (CASL); a plain member only manages their own scope: user. The config-app connector flow is admin/editor so the workspace token write succeeds; a non-editor end-user connecting via an agent connect_url stays interactive-only. "Best-effort" is not a comment — it MUST be enforced: wrap the run: module: secrets … scope: workspace write in a try/catch (see reference/automations/oauthCallback.yml). Otherwise a non-editor end-user hits an uncaught SecretsError "create on secure_secrets" that aborts the whole callback (scary error + broken redirect) even though their user-scope token was already persisted and the agent works fine.
-
Two secret stores, don't confuse them. PATCH /workspaces/:id/security/secrets writes SubjectType.Secret (config secrets, resolved via {{secret.X}} 2-hop binding). The secrets runtime module (set/get/delete, returns refs) writes SubjectType.SecureSecret. They are DIFFERENT — don't write with one and read with the other.
-
MCP notifications → 202. mcp.yml must answer any JSON-RPC message with no id (e.g. notifications/initialized) with HTTP 202 (set $http.status: 202), not a JSON-RPC object, or the agent never registers tools and the LLM hallucinates tool names.
-
CSP blocks inline <script>. Webhook-served HTML runs under script-src 'self'. OAuth redirect pages must rely on <meta http-equiv="refresh"> only (+ a fallback <a>); never an inline <script>. The countdown/auto-close lives in the SPA bundle (allowed), reached via the redirect.
-
OAuth host ≠ UI host. Providers serve OAuth/API on a specific host, never the console/UI domain (Salesforce: *.my.salesforce.com, never *.lightning.force.com). Normalize via the host helper. Same trap when configuring the CLI/Connected App.
-
Token-exchange body = object. form-urlencoded fetches send body: as a YAML map (the runtime serializes it). A string body fails (unsupported_grant_type).
-
Agent identity. agent-factory injects context_id,agent_id,user_id (capability scope) into the tool-call arguments AND propagates user_id as the runtime user. So per-user OAuth works for interactive/agent-piloted calls (user-scope). For cron use a workspace store keyed by userId + a server-only targetUserId — NEVER source it from tool/LLM arguments; mcp.yml must call buildAppAuth without targetUserId.
-
2-hop binding. {{secret.X}} resolves only inside tenant workspace config.value. Build the literal binding strings with Custom Code (makeConfigRef/makeSecretRef) so DSUL never parses {{. onInstall does GET-merge-PATCH on /config (don't clobber). push_workspace does NOT update runtime config.value.
-
SPA theme + cache. The SPA's CSS (Tailwind utilities and the shadcn --vars) is provided by the host socle — it is NOT bundled (the workspace globals.css is dev-only; the built bundle.js contains no .dark/--secondary). The studio toggles .dark on <html> per its theme (hooks/useTheme.ts, default light) and the SPA inherits it automatically. So: (a) never inject an @media (prefers-color-scheme: dark) palette override (the old useDarkVars/DARK_VARS) — it follows the OS not the studio and pins the config app to dark whenever the user's OS is dark; (b) for buttons prefer variant="outline" (bg-background + inherited foreground → always contrasts) over variant="secondary" — the socle's --secondary can render low-contrast / white-on-white in dark. Bundle URL must change per deploy (cache). externals.mjs must match the socle (no extra radix). Platform API from the SPA: Bearer sdk.token + x-prismeai-csrf-token: sdk._csrfToken + credentials:'include'.
-
Dynamic dispatch is intentional. The ~N per-op automations have no static references (reached via the registry) — reviewers must not flag them as orphaned.
-
tools/list is served from imports/MCP Core.yml config.mcpTools — NOT index.yml. Put the entity tool schemas in the MCP Core import config (keep index.yml mcpTools in sync if present, but the import config is what MCP Core actually returns). When mirroring the reference, the entity names (records, query, …) are NOT placeholder-renamed by sed — rewrite the whole mcpTools block for the new service. Placement (STRICT): the index.yml sync copy lives under config.value.mcpTools — NEVER at config level (config.mcpTools). The swagger Config schema is additionalProperties: false (only schema / block / value), so a top-level config.mcpTools makes EVERY client-side PATCH /workspaces/{id} fail with 400 RequestValidationError — silently, because push_workspace/import write the DSUL server-side without gateway validation, so it only surfaces later when the workspace is imported (workspace-importer) or its config is edited from a UI/SPA. azure-ocr + jira-next both hit this (fixed 2026-07-09; the whole App+MCP fleet was scanned — those were the only two). Verify before every push (must print ok): python3 -c "import yaml;c=yaml.safe_load(open('index.yml'))['config'];assert 'mcpTools' not in c,'mcpTools must be under config.value, not config';print('ok')". (This is workspace config only; the imports/MCP Core.yml config.mcpTools is an AppInstance config — schema {}, any keys allowed — and is correct as-is.)
-
Multi-host services (e.g. Google: Drive/Docs/Sheets/Gmail/Calendar each on a different host): put a per-op base in the OPERATIONS registry, have buildApiRequest return it, and have methodRestOp pass req.base as baseUrl to executeApiCall (buildAppAuth returns only {accessToken}). Single-host services keep one baseUrl from buildAppAuth.
-
Naming convention (strict). Automation slug: is camelCase only — no kebab/snake (methodRestOp, not method-restOp). The name: (Builder display path) uses plain folders with no numeric prefix (MCP/endpoint, Helpers/buildAppAuth, Methods/methodRestOp, Tools/toolRestOp, OAuth/oauthConnect) — never 00_MCP/…. Versioned REST endpoints may keep a slash path slug (e.g. v1/status). Callers invoke an automation by its slug, so renaming a slug means updating every caller + the filename.
-
Studio/console URL: use {{global.studioUrl}}. For any link back to the front-end — configAppUrl in onInstall, the OAuth SPA redirect in oauthCallback/oauthDisconnect — use {{global.studioUrl}} (full URL with scheme, resolved per inbound API host → multi-domain aware). NEVER derive it from {{global.apiUrl}} (e.g. replace(URL(...).hostname, "api.", "")): that api.<front> assumption breaks for clients whose front/API hosts differ. Build the link as {{global.studioUrl}}/apps/<slug>?… — no https:// prefix, no intermediate studioHost.
-
Exposed endpoints use slug:<slug> by default; the raw id is used only for two documented exceptions. In onInstall, resolve both forms once via GET /workspaces/{{global.workspaceId}} → wsInfo.id (raw) + wsInfo.slug, then:
- Default (slug): build exposed URLs as
slug:{{wsInfo.slug}} — e.g. the oauthCallbackUrl for non-Microsoft providers. Why slug, not id: while you debug an install you re-import the workspace, which changes the workspace id every time but keeps the slug stable — a slug-form callback stays valid across attempts, an id-form one would have to be re-registered in the provider console on each re-import. (The slug rename concern is real but rare; the re-import-during-dev concern is constant.)
- Exception 1 — Microsoft Entra OAuth callback → raw id (
wsInfo.id). Entra rejects the : of slug:<slug> in a redirect URI (RFC 3986 allows it; Entra is stricter). Google/Salesforce tolerate it → they keep slug. So this exception is Microsoft-only, not "all OAuth". See the powerbi connector. Remember the redirect URI must match byte-for-byte across authorize, token exchange, and provider-console registration.
- Exception 2 —
/config + /security/secrets → raw id (wsInfo.id). Those endpoints do NOT resolve slug:<slug> (404); they require the raw id. The configAppUrl passes workspaceId={{wsInfo.id}} (the SPA needs the raw id for /security/secrets) AND workspaceSlug={{wsInfo.slug}} (so the SPA builds its slug-form MCP endpoint).
Never store mcpEndpoint on the instance config (no schema field, no set, no emit/output): the config SPA derives it client-side from the workspaceSlug it gets via configAppUrl, and exposes it (display + "Install capability").
-
validate_automation is authoritative. Run it on every automation. push_workspace version name ≤ 15 chars. Nested expressions need inner {% %}; replace(...) all-occurrences via JS split().join() in Custom Code; matches/and/or only at condition level.
-
Externally-triggered anonymous webhooks CANNOT read secrets (platform bug — ticket filed, revert once fixed). An automation reached by an external POST with no auth — a provider push subscription (Gmail Pub/Sub), an incoming third-party webhook/callback — runs with NO principal. In that context, neither secret store is readable: run: module: secrets, get, scope: workspace → not_found even though the secret exists, and GET /security/secrets with a freshly minted auth: workspace JWT returns a body without the secrets. The secret-backed config.value bindings ({{secret.X}} / the 2-hop config.<camel>X) resolve only intermittently there. The ONLY things that resolve reliably are Collections and plaintext instance config (config.X where X is written in clear via set: config / update_app_instance_config). Proof (debug emit inside an anonymous push): jwtPresent:true, a plaintext config.value key resolves true, but every secret read resolves false. So, for any push/callback-driven connector:
- Per-user refresh tokens + the client_secret used to refresh them → store in a Collection (plaintext) on the central/connector workspace (a central anonymous resolver reads them there), not workspace secrets. NOT inherited by consumers.
- Per-tenant config the anonymous webhook reads (push token,
targetUserId, agentId, agent API key, topic) → plaintext instance config, NOT secrets nor {{config.<camel>X}} bindings. Pattern: a syncConfig endpoint automation mirrors the tenant secrets → set: config plaintext, called by the SPA after every save; onInstall writes the instance config in plaintext (terminal set: config); and you MUST remove the binding-A defaults (watchTargetUserId: '{{config.<camel>…}}' …) from index.yml config.value — otherwise they re-inherit on every re-publish and silently overwrite the plaintext (the symptom: "it worked, a redeploy broke it").
- Cache gotcha: a consumer instance caches its automations (~minutes TTL); a source
push_workspace is not picked up immediately, but publish_app forces the refresh. So after this kind of change, re-publish and verify (get_app_instance_config should show plaintext, not {{config.…}}).
- Reference implementation:
gmail-reply-agent / app GmailReplyAgentNext (Collections OAuthConnections for refresh tokens + CentralOAuthClient for the client secret; syncConfig + plaintext instance config). Note: a schedules: cron triggered by the runtime may still have a workspace principal — but if a connector is push-driven, don't bet on secrets; use Collections/plaintext.
-
Agent picker: paginate + broad scope. The GET /workspaces/slug:agent-factory/webhooks/v1/agents list endpoint defaults to limit=20 (max 100) and scope defaults to own. A bare ?scope=own fetch therefore silently shows only the 20 most-recently-updated owned agents — tenant-owned-but-older or shared/published agents are missing. The config-app agent list MUST: (a) request scope=own,shared,published (comma-separated; covers everything the user can reach), and (b) paginate (&limit=100&page=N, loop until batch.length < limit or collected.length >= total, cap ~20 pages). Auth is the user session (apiHeaders(sdk) Bearer + CSRF + credentials:'include'), so scope=own resolves against the logged-in user — pagination/scope is the fix, not identity. Applies to BOTH the MCP-server allowlist picker (gryzzly, salesforce) and agent-calling connectors (google-chat, gmail-reply-agent).
-
Connectors that CALL an agent need an agent-scoped API key — auto-provision it, don't make the user paste one. This is the opposite direction from the MCP-server connector (where the agent calls us via injected scope, no key minted — Gotcha 8/26). A connector that queries an agent (e.g. Google Chat, Gmail Reply Agent → Agents.sendMessage / /messages/send) authenticates to agent-factory with an iak_ key. Mint it from the SPA via the agent-scoped endpoint, NOT the org-level one: GET /agents/{id}/api-keys to find an existing connector key (slug-prefixed) → POST …/{keyId}/rotate for a fresh raw secret, else POST /agents/{id}/api-keys body {slug:'<connector>-<id>-<rand>', name, permissions:['agents:read'], expiresAt}. Read the raw key from data.apiKey || data.key || data.value, store it in the tenant secret. permissions:['agents:read'] is sufficient for messages/send. Do NOT use /orgs/{slug}/api-keys (needs org-slug resolution + ownerType/scopes — fragile, often falls back to manual). Keep a manual-paste fallback.
-
LLM-safe mcpTools or the LLM "crashes". Strict providers (OpenAI/Azure) validate function schemas at call time: a dangling $ref in any enabled tool's inputSchema → 400 on EVERY completion ("the LLM crashes as soon as one MCP tool is enabled"); a tool description > 1024 chars → same on Azure. Neither is caught by validate_automation — only an audit of the live tools/list JSON catches them (count "$ref", measure descriptions, walk for type: array without items). Fleet was audited+fixed 2026-06-12 (tableau had 17 $refs from swagger copy-through; monday 2; 17 oversized descriptions across 7 connectors).
-
Bound every tool output (an unbounded result blows the agent's context window). Three layers, all in the generic infra: (a) buildApiRequest clamps pageSize/page_size query params to 100; (b) toolRestOp passes apiResult.data through a trimToolResponse Custom Code before formatToolOutput: arrays capped at 100 items, nested reference-like objects ({id,…} ≤6 keys at depth ≥1) collapsed to {id,name,type,contentUrl}; (c) string bodies (CSV…) are wrapped as {__mcpText, text} truncated at ~20k chars on a complete line with a "showing N of M rows — use filters" suffix, and toolRestOp returns them as plain text content directly — NEVER through formatToolOutput (its json() crashes InvalidExpressionSyntax on non-JSON). Tool descriptions must tell the LLM the server-side narrowing params (filters) exist.
-
Binary ops cannot transit DSUL fetch. A binary response body (PNG/PDF/XLSX/TWBX…) arrives as an empty {} (fake success) — and historically as a giant byte-array that floods the agent. Keep an isBinaryOperation(operationName) Custom Code list and short-circuit those ops in toolRestOp BEFORE calling the API, returning an explanatory message ("binary not available inline — call get and share webpageUrl; use the CSV/data action for the underlying data"). Also mark them "NOT available inline" in the tool description so the LLM doesn't even try. Long-term re-host to /v2/files is a separate chantier (upload credential needed).
-
Retry-on-401 for revocable session tokens. Some providers allow a SINGLE active session per credential (Tableau PAT: any concurrent signin — a script, a test, another agent session — silently revokes the cached token before its TTL). Pattern (see tableau wcsl8RE): (a) buildAppAuth takes forceRefresh: boolean that bypasses its session cache; (b) methodRestOp/methodGraphqlOp on status == 401 → buildAppAuth(forceRefresh: true) → replay ONCE (guard: only if the fresh token differs / config auth resolved — no-op in tenant-injection mode); (c) MCP path: toolRestOp/toolGraphqlOp error results carry _status, and mcp.yml on _status == 401 re-fetches getConfig?forceRefresh=true (getConfig forwards it to buildAppAuth), updates session.tenantCreds, and replays the tool call once. Without (c), the App-mode retry is dead code on the MCP path — the central workspace has no config creds.
-
GraphQL warnings ≠ errors. Some GraphQL APIs (Tableau Metadata) return a non-fatal errors array ALONGSIDE valid data on 2xx (e.g. "results filtered by permissions"). handleApiError must treat 2xx errors as fatal only when data is absent — otherwise every permission-scoped query falsely fails.
-
App config.value secret refs DO NOT resolve in tenant instances. Instances inherit the app source workspace's config.value as defaults (runtime workspace.ts Workspace.create merges dsul.config.value under the instance overrides), BUT {{secret.X}} refs inside config values interpolate at runtime against the RUNNING workspace's secret store (workspace.ts updateConfig) — the app source workspace's secrets are NEVER consulted from a tenant. So a core binding like centralAuth: '{{secret.<camel>CentralOAuth}}' resolves on the core (its own store) but stays a literal string in every tenant instance (with undefinedVars: 'leave'). Consequences: (a) never design a "shared secret via app config defaults" — it silently doesn't work (the symptom: the connector works when tested on the core, fails on every tenant); (b) anything central+secret needed by tenants must be served operationally by core webhooks: public parts via a read webhook (getOAuthClientPublic), privileged operations via a proxy that uses the secret server-side (centralTokenExchange) — the token-service model; (c) when reading a maybe-unresolved binding, test a PROPERTY (!{{c.oauthClientId}}) — property reads on the literal string yield empty, while a matches "{{" guard crashes (Gotcha-adjacent: feedback_dsul_literal_braces_in_expr). Also remember the app config is snapshotted at publish_app — tenant instances only see core config.value changes after a re-publish (instances cache automations ~minutes; publish forces the refresh).
-
Agents allowlist MUST save live on each tick — NO separate "Save allowlist" button. The "Authorized agents" section gates MCP tools/call via validateAgent (allowlist secret <camel>AuthorizedAgents). The "Autoriser tous les agents" toggle already persists * immediately, but the per-agent checkbox list historically only mutated local state and required a separate Save button — users tick an agent, never click Save, the secret stays empty → validateAgent returns not_allowlisted and EVERY agent is denied. It looks like a backend bug: OAuth/config can be fine in the same call (oauthStatus connected:true, written server-side by oauthCallback) while the SPA-written allowlist is empty. Diagnose via search_events on the instance workspace → isAgentAllowed receives list: "", validateAgent → {valid:false, reason:"not_allowlisted"}. Fix (in reference/): toggleAgent is async + persists on every check/uncheck like toggleAllowAll — optimistic setAuthorized(next) → await patchSecret(ALLOWLIST_SECRET, Array.from(next).join(',')) → toast msg.allowlistSaved, rollback setAuthorized(prev) on failure; disabled={busy} on each agent checkbox. NO saveAuthorized function, NO btn.saveAllowlist button/i18n key. Fixed in reference/ + salesforce + google-workspace 2026-06-15; rest of the fleet not retrofitted.
-
MaintainerSetup MUST gate on maintainerStatus, not on the secrets GET — a non-maintainer must NEVER see the editable form. The maintainer view (!readParam('workspaceId')) historically prefilled from GET /security/secrets and assumed a 401/403 there meant "not a maintainer". It doesn't: Secrets.getSecrets does a secured accessManager.findAll(SubjectType.Secret) → for a non-privileged but authenticated user it returns an empty 200 {} (NOT 403), indistinguishable from a maintainer whose central client isn't set yet. So the form rendered (empty) to everyone, with only a small error line; the user could click Save (the write was still blocked server-side by setOAuthClient's 403, but the UX implied otherwise). Fix (in reference/central-oauth/): add the authoritative maintainerStatus.yml endpoint — returns {allowed} from user.role ∈ owner/editor/admin OR user.platformRole = "superadmin" (same gate as setOAuthClient) + the public clientId/scopes for prefill (NEVER the secret). MaintainerSetup calls it FIRST in the effect; !allowed → setForbidden(true) → render an "Access restricted" card (maint.noAccessTitle/maint.noAccessBody, en+fr) with NO form, NO Save. Deployed google-workspaces 2026-06-15; fleet retrofitted with the platformRole clause 2026-06-19. user.platformRole clause (2026-06-19): {{user.role}} in an automation is ONLY the workspace-direct role (contexts/factory.ts overwrites it with getLoadedSubjectRole(Workspace,id)), so a platform SuperAdmin (or an owner inherited via the org) who is not a direct member gets an empty user.role and is wrongly denied. The runtime context DOES expose {{user.platformRole}} (the worker loads the user via fetchMe; factory.ts clobbers only .role, leaving .platformRole/.groupAcls intact — verified), so both gates include … || {{user.platformRole}} = "superadmin" (and the negated … && {{user.platformRole}} != "superadmin" in setOAuthClient). NO platform change is needed.
-
Global/central MCP endpoint accepts ALL agents — validateAgent short-circuits on config.centralAuth. When an agent capability targets the global endpoint (the CORE workspace's own mcp webhook, no tenant), config.authorizedAgents is empty in the core context → validateAgent returned not_allowlisted for every agent. The per-agent allowlist is a tenant feature; the global endpoint's real gate is per-user OAuth. Fix (in reference/automations/validateAgent.yml): first step reads central: '{{config.centralAuth}}' and, if {{central.oauthClientId}} is truthy, returns {valid:true, reason:'global_endpoint'}. config.centralAuth resolves ONLY in the core context (Gotcha 26) — tenant instances see the unresolved literal so central.oauthClientId is empty there and the allowlist still applies unchanged. Only ship this on central-OAuth connectors that expose a global endpoint. Deployed google-workspaces 2026-06-15.
-
One-click "Add to catalog" — CatalogPublish (existence-gated POST /v1/servers). The org-wide Capabilities catalog (workspace capabilities, 3ueUyns) is what Agent Factory reads when a builder adds a catalog-backed tool; the platform's own central-OAuth connectors (Figma, Gitlab, Google Search) ARE custom mcp entries there. The SPA component src/CatalogPublish.tsx automates registering the connector instead of the old "paste this JSON into Governance" flow. API: GET|POST /workspaces/slug:capabilities/webhooks/v1/servers (+ PATCH /…/:id). An mcp entry's config_schema.properties.server.default IS the MCP endpoint; OAuth connectors carry an auth block {type:'oauth2', status_url, connect_url, disconnect_url, scopes} — oauth2 is the catalog convention, distinct from the oauth type used in the per-agent POST /agents/:id/tools install (don't confuse them). Gating ("only enable if it doesn't exist yet"): on mount it GETs ?type=mcp&built_in=false and matches an entry whose server default === our endpoint (match on the URL, NOT the name — names collide); present → "✓ Already in the catalog" + an Update (PATCH); absent → an enabled Add (POST). Where it mounts: central-OAuth connectors put it in MaintainerSetup on the CORE endpoint slug:<slug>/webhooks/mcp (one org-wide entry; per-user OAuth is the gate — Gotcha 29), disabled until the central client is saved; static-token / per-tenant connectors mount the SAME component in the tenant ConfigApp on the per-tenant mcpEndpoint. Auth: the user's Studio session (Bearer + CSRF + credentials:'include'), entry org-scoped to their active org server-side, 403 → cat.forbidden. The entry is org-wide (NOT per-agent) — say so in the helper text (cat.hint). i18n: cat.* (en+fr). Role gate (UI stopgap): the catalog write API currently has NO server-side role check — capabilities's _auth only resolves owner_id + session.org.slug, so ANY authenticated org member (builder/member) can POST/PATCH/DELETE. Until the platform adds a role gate there, CatalogPublish SELF-HIDES unless the user is an org owner/admin: on mount it GETs /me and checks me.org.role.slug ∈ {org:owner, org:admin} (PRIVILEGED_CATALOG_ROLES), reading the ACTIVE-org role (the same org the write runs under). This is UI-only (NOT a security boundary — a direct API caller still bypasses it); the authoritative fix is a DSUL role gate in the capabilities workspace (automations/v1/servers.yml + server_id.yml + _auth.yml). The component renders its own cat.title heading and returns null when hidden, so call-sites are a bare <CatalogPublish/>. Shipped in the reference SPA; not retrofitted to the existing fleet.
-
Inline config UI via config.block (preferred over the configAppUrl link). The one-product builder (services/platform/builtin-apps/builder → ImportEditorPane/ConfigBlockView) renders an installed app's config.block (a CJS bundle URL, a sibling of config.schema/config.value in the AppInstance Config type) inline as the config UI, replacing the JSON-schema form — exactly how Custom Code swaps the form for a specialized editor. This surfaces the connector's rich config SPA (OAuth connect, agent allowlist) right inside the tenant's import editor instead of a configAppUrl link the user must click. Wiring: (a) set config.block: <bundle-url> in index.yml under config: (sibling of schema/value), pointing at the SAME bundle as config.value.bundles[<slug>].bundle, and keep the two in sync on every redeploy — deploy.mjs only patches config.value.bundles, so also re-point config.block to the new hashed URL in the same full-config PATCH (else the inline UI loads a deleted bundle → 404). (b) Host-context inversion — the critical SPA consequence: via config.block the SPA is mounted inside the TENANT's Builder, so the workspace prop is the tenant (NOT the connector's own workspace as on the /apps/<slug> route) and there is no ?workspaceId=/?appInstance= query param. The platform passes appInstanceSlug + connectorSlug as props. The SPA must therefore read tenant from props.workspace.id (with readParam('workspaceId') as legacy fallback) and instance from props.appInstanceSlug || readParam('appInstance'); route the maintainer view by host identity (workspace.slug === CENTRAL_SLUG), NOT by the absence of ?workspaceId= — the old guard wrongly shows MaintainerSetup under config.block, whose maintainerStatus call then 404s on the tenant → "Access restricted" (the symptom). Build all central/maintainer webhook URLs from the fixed slug:${CENTRAL_SLUG}, never from centralSlugOf(workspace). (c) Platform dependency: rendering config.block requires the builder feature (ImportEditorPane reads appInstance.config.block and mounts it via ConfigBlockView with { sdk, user, workspace: <tenant>, appInstanceSlug, connectorSlug }); until it is live in an environment, the configAppUrl readOnly field/link is the graceful fallback — keep shipping it. The non-central reference SPA already resolves tenant/instance from props, so it is config.block-ready; only the central-OAuth router + MaintainerSetup central URLs needed the CENTRAL_SLUG change (done in reference/central-oauth/MaintainerSetup-excerpt.tsx). Reference: google-workspaces (config.block + SPA routing fix, 2026-06-22).
(d) config.block reaches a tenant instance ONLY after publish_app. getAppInstance returns config = { ...getApp(appSlug).config, value: instance.config }, and getApp reads the published App DSUL (apps.ts getApp), NOT the live workspace config. So setting config.block (or config.value.bundles) on the connector workspace does NOTHING for installed/reinstalled instances until you publish_app to snapshot it (verify: GET /apps/<AppSlug> pick:['config'] → config.block present). Bit hard: a reinstalled tenant showed no inline SPA until the connector was re-published.
(e) THREE render surfaces, two bundle pointers — update BOTH and publish. (1) the inline config UI (one-product builder) reads appInstance.config.block; (2) the /apps/<slug> page, the Builder maintainer view, and the configAppUrl link all render config.value.bundles[<slug>].bundle; (3) the Builder native "Aperçu" of the index page compiles the workspace's editable source files (metadata.type=source) — a SEPARATE copy from the bundle. A real redeploy must therefore update config.block AND config.value.bundles[<slug>].bundle (same new URL) AND publish_app — deploy.mjs does all of it via a dedicated publish step that runs BETWEEN the config PATCH and cleanupOrphanBundles (publish snapshots the new bundle into the store BEFORE the old one is deleted; reversing the order 404s every instance). Updating only config.block leaves the page/maintainer view serving the OLD bundle. ⚠️ Historical footgun (fixed 2026-06-25): the publish step was actually MISSING from the original deploy.mjs template + the whole fleet — deploy patched the workspace, cleanup deleted the old bundle, but the published app (hence every installed instance, which reads config.block from getApp, NOT the live workspace) still pointed at the deleted bundle → instance config UI 404. Older fleet copies that predate the fix need the publish step added (5 inserts: SKIP_PUBLISH/PRISMEAI_APP_SLUG env, publishApp() fn = POST /v2/apps {workspaceId}, steps[] entry, RECOVERY entry, the call in the run block). Fix a live 404 without redeploying via publish_app(workspaceId) (version name ≤15 chars; no slug = safe update). (The config SPA can also drop its outer Card frame + " — configuration / Workspace " header and render full-width — <div className="w-full p-6 space-y-6"> — since under config.block it's mounted inline.)
(f) MCP-only redeploy of config.value.bundles (no deploy.mjs token). PATCH /workspaces/:id/config (route patchWorkspaceConfig) shallow-merges top-level keys schema/block/value — so {block: url} updates block alone, but sending a hand-built value REPLACES it (dropping the huge mcpTools). To update config.value.bundles[<slug>].bundle while preserving mcpTools without a token, run a private one-shot automation: auth: workspace:true → GET /workspaces/<id>/config → set newValue = cur.value; set newValue.bundles.<slug>.bundle = bundleUrl → PATCH /config {value: newValue, block: bundleUrl} (the full value is read server-side, never re-inlined). Then publish_app. (Validated pattern _syncBundlePointer.) CORRECTION (2026-06-26): the runtime PATCH /config updates ONLY the live workspace config — it does NOT touch the DSUL document that publish_app snapshots, so installed TENANT INSTANCES (which read config.block from getApp = the published DSUL) keep the OLD bundle. _syncBundlePointer alone suffices only for the connector's OWN /apps/<slug> rendering, NOT for tenant instances. The reliable MCP-only redeploy to instances: edit config.block + config.value.bundles[<slug>].bundle in the LOCAL index.yml, push_workspace (prune:false → writes the new block into the DSUL document), THEN publish_app. Verify the default (un-versioned) GET /apps/<AppSlug> pick:['config'] shows the new block — a ?version=<name> read can show the new block from a fresh version snapshot while the served default still lags. Caveat: the hyphenated bundle key (<slug> containing -) breaks {% %} reads inside the helper → keep the version literal and rebuild the bundles map as a fresh YAML object (hyphen is fine as a literal YAML key, not as a {% %}/set-path segment).
(g) NEVER edit the Builder's metadata.type=source files via MCP while the Builder is open. The Builder OWNS the editable source (loads it into a sandbox, re-syncs/saves it); delete+re-upload via MCP races the Builder and gets clobbered, corrupting the preview. Edit SPA source only via deploy.mjs source-sync or the Builder Code view.
(h) Router: keep the OAuth callback unconditional — if (readParam('view') === 'oauthCallback') return <OAuthCallbackView>. Do NOT try to gate it on window.name/window.opener to keep a lingering ?view=oauthCallback from showing in the Builder preview: the browser CLEARS window.name on the cross-origin round-trip to the OAuth provider (and window.opener is unreliable), so any such guard makes the REAL callback popup fall through to the config page instead of the "connected" screen (regression hit 2026-06-23 on gitlab, reverted). The preview-shows-callback case is a harmless cosmetic edge (a stale query param) and clears on navigation — not worth breaking the real flow.
-
Platform bug — file uploads dropped metadata (FIXED 2026-06-23 ~11:30, commit ada8572b7 fix/native-files-upload-metadata). Before that fix, POST /workspaces/:id/files didn't persist the uploaded metadata (neither the top-level metadata JSON object nor the per-key metadata.<key> multipart fields → services/workspaces/src/utils/uploadMetadata.ts). Two consequences when an env runs an OLDER build: (a) MCP upload_file's metadata arg is silently lost — follow it with PATCH /workspaces/:id/files/:id {metadata:{path,type:'source',hash}}; (b) the Builder's "Save" of SPA source (which sends metadata.path) drops it, so loadFilesFromWorkspace falls back to file.name (bare, no src/ prefix) as the sandbox files-map key → the native preview's @/x → src/x alias resolution FAILS (relative ./x still resolves). Symptom: @/lib/utils red-underlined / (0, import_utils3.readParam) is not a function, while ./lib/utils works. The compiler/alias is CORRECT (resolveInVfs in services/platform/src/lib/compiler.ts maps @/x → src/x); root cause is the dropped metadata. Once the env deploys ada8572b7, keep @/ imports — no connector change needed; just re-save / redeploy so files regain metadata.path.
-
CatalogPublish/MaintainerSetup catalog auth block MUST use the connector's REAL webhook slugs (oauthConnect/oauthStatus/oauthDisconnect), not the legacy initiateOAuth/checkAuthStatus/disconnectOAuth names. The org-wide catalog entry's auth.connect_url (Gotcha 30) is what Agent Factory opens when a builder's agent initiates OAuth. If it points at a webhook the connector doesn't expose, the agent's connect flow dies with ObjectNotFoundError: "Did not find any matching trigger for endpoint initiateOAuth" — even though the per-agent "Install capability" button and the tenant Connect button work, because those build their URL from wh('oauthConnect') (the real slug) while only the catalog auth used the wrong name. Diagnostic tell: the per-agent/appinstance Connect works but the agent-proposed OAuth 404s on initiateOAuth. The app+mcp connectors (salesforce-next family) expose oauthConnect/oauthStatus/oauthDisconnect; the legacy initiateOAuth/oauthCallback names belong to the OLD oauth-core/google-docs/google-mail/gmail-reply-agent connectors and must NOT be copied into a new connector's catalog block. Found+fixed 2026-06-26 in google-workspaces (the lone outlier — built first from a stale MaintainerSetup-excerpt.tsx; the rest of the fleet already used the correct slugs). Fix is in reference/central-oauth/MaintainerSetup-excerpt.tsx (catalogAuth). When this is wrong on a LIVE connector, also PATCH the already-registered catalog entry (PATCH /workspaces/slug:capabilities/webhooks/v1/servers/:id {config:{auth:{connect_url,status_url,disconnect_url}}}) in EVERY env — fixing the SPA only corrects FUTURE Add/Update clicks.
When done: the workspace path + id, the published app slug, the MCP endpoint, the configAppUrl, the auth modes wired, and the smoke results (handshake 202, tools/list count, a tool call, OAuth connect/disconnect). Commit on the worktree branch only when the user asks.