npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@collective-intelligence/external-apps-server-sdk

v0.1.38

Published

Server-side client and proxy adapters for Collective Intelligence external applications.

Readme

@collective-intelligence/external-apps-server-sdk

Confidential server client and proxy boundary for Collective Intelligence external applications. Import administrative APIs only from /admin; import the optional Express runtime adapter from /runtime/express.

The /admin client exposes knowledge connector administration through knowledgeConnectors (formerly connectors). It calls the backend's /admin/extensions/knowledge-connector-* resources. Upgrade callers and the backend together; the previous namespace and API routes have no compatibility aliases. Request and response shapes are unchanged.

The customer-hosted knowledge gateway is intentionally split into isolated subpaths:

  • /gateway owns deterministic defineGateway manifests, explicit one-level operation domains, proposal projection, and the Web Request/Response policy kernel;
  • /auth verifies short-lived ES256 CI caller JWTs through tenant-bound JWKS, including audience, installation, organization, capability binding, expiry, and atomic jti replay consumption; and
  • /resources declares promise-based resource handlers over the portable client resource contract.

Packages and peers

effect (^4.1.0) is a required peer. Install it next to the SDK and keep one effect version in the application. The SDK depends on @collective-intelligence/external-apps-client-sdk and @collective-intelligence/extensions-sdk at the same release and does not bundle their JavaScript, so the application has one copy of their error classes. /effect is the Effect export and /next is the Next.js export; see Effect authoring and Next.js, Web and Express hosting.

Subpath migration

Three subpaths are deprecated re-exports for one minor release. Each exports the same values as its replacement, so classes and service tags have one identity. Each name that a subpath takes from its replacement is marked @deprecated, and the ci-solution/effect-subpath lint rule reports the subpaths. The replacements do not export the types of the sibling SDKs that the deprecated subpaths kept from 0.1.x: import them from @collective-intelligence/extensions-sdk or @collective-intelligence/external-apps-client-sdk/resources.

| Deprecated subpath | Import instead | | ----------------------------- | -------------- | | /runtime/effect | /effect | | /persistence/drizzle/effect | /effect | | /runtime/next | /next |

Errors

The SDK uses the tagged error classes of @collective-intelligence/external-apps-client-sdk. The package root and /effect re-export CiError, CiTransportError and FederatedAuthError; they are the same classes, so instanceof gives one answer in the application.

  • A CI error envelope stays a value: { ok: false, error: { correlationId, error } }.
  • A request that does not reach CI, or an answer that is not readable, rejects with CiTransportError; cause holds the original failure.
  • When the caller aborts through signal, the call rejects with signal.reason.
  • Bad input, such as a bad tenantUrl or a body that JSON cannot write, throws TypeError.
  • defineCiEntityResource keeps each failure as a value of its ResourceResult, with transport_error and its outcome.
  • GatewayEffectRuntimeError is a Schema.TaggedError; new GatewayEffectRuntimeError(cause) keeps its positional form.

createServerClient and runAsApplication return a client with close(), which disposes the runtime of the client. A call that still runs is interrupted.

Choose authentication by boundary

The SDK has three separate authentication boundaries:

  • createServerClient uses confidential service credentials for requests from the customer server to CI. Keep those credentials on the server and use client mode service, or delegated mode with a member access token from exchangeMemberSession to act as a member. Its tenantUrl must be a credential-free HTTPS origin; for local development it also accepts an http origin whose host is exactly localhost, a 127.0.0.0/8 address, or [::1]. Any other http host is rejected before credentials are sent.
  • Browser-facing application routes use either CI federation from /auth/federated or local email/password sessions from /auth/better-auth. Select one mode for a deployment and authorize those sessions at the application route boundary.
  • Gateway routes accept short-lived ES256 caller tokens signed by CI. The customer server verifies those tokens and their live connection grant through /auth; browser cookies do not authorize Gateway calls.

Administrative CI APIs use a separately supplied administrator bearer token through /admin. None of these credentials or grants belongs in a Gateway manifest.

A confidential application created by package install has no clientSecret until an administrator with external_apps:manage rotates its credential (POST /admin/organization/external-apps/{id}/credential/rotate, or Rotate secret in the Dashboard). CI returns the secret once; store it server-side. See Provision a secret for a package-created application.

Gateway manifests contain only serializable declarations. Handlers, credentials, signing keys, resolved identities, and customer authorization callbacks stay in the runtime definition. Public principals are the closed detached_user | service_principal union. A delegated customer identity is retained separately as the stable external issuer/subject audit context and never changes caller authority.

Gateway protocol version 2 exposes Knowledge resources as remote source metadata only. A resource definition describes the external application's own record shape and capabilities; it never declares a CI organization domain, entity schema, relation schema, schema FQN, or field mapping. Organization administrators select exposed resources and map them to organization-owned schemas when configuring the corresponding CI Knowledge connector. Push ingestion follows the same boundary: records contain source identity, revision, timestamps, deletion state, and the remote value only. They cannot declare organization relation edges or relation fields; CI derives those semantics through the organization-owned connector mapping. createGatewayIngestionClient from /gateway accepts the same tenantUrl values as createServerClient, including a loopback http origin for local development.

Each defineOperation must fully materialize the policy CI reviews: portable input/output schemas, effects, idempotency, retry, authorization, execution, limits, and version. retry is either { mode: "never" } or an idempotent_only policy with 2–10 total attempts. Execution is synchronous and declares a finite timeout. maxAttempts counts the initial call and every permitted retry. limits bounds the request body and serialized result. defineGateway rejects non-integer or unsafe bounds before hashing the manifest: timeouts are at most one hour and request and response payloads at most 16 MiB. Background execution is rejected at registration.

The kernel validates an optional x-idempotency-key once and forwards the exact value to the operation handler. A requires_key operation is never called without one. Replay lookup and durable result storage belong to the handler and the customer's persistence layer; the framework-neutral kernel does not claim generic deduplication.

createGatewayKernel serves the closed versioned discovery, manifest, health, resource and domain-qualified synchronous operation routes. It does not expose background execution handles, polling, cancellation or event-stream routes. The kernel applies each operation's request, timeout and result bounds; the gateway-global body limit remains an additional ceiling. An already-aborted request never starts an operation. A live abort signals the handler and returns a cancellation response; completed requests detach their abort listener. A handler must cooperate with its signal to stop its own work. Cancellation does not undo an already-committed mutation. Runtime adapters translate framework values to that kernel instead of reimplementing routing, authorization, or error policy. Provider and framework peers are optional and isolated to their declared /knowledge-sources/*, /persistence/*, /runtime/*, and /tools/* exports; importing the package root or the gateway/auth/resource subpaths does not load them.

The configured tenantUrl must be a credential-free HTTPS origin, without a path, query, or fragment. Mount createExternalApplicationBff (or createExternalApplicationBffNextHandlers) once for federated auth, Resource proxy, and dedicated Capability forwarding. Pass ciResources to bind one Resource proxy mount per entity contract by schemaFqn; the BFF authorizes those mounts with authorizeWithUserAuth. Dedicated Capability forwarding sends the signed-in user's delegated token and the caller's x-organization-id by default. For an application without federated CI users, pass capabilities: { authentication: { mode: "service", credentials }, getAuditSubject, organizationId, tenantUrl }: each call carries the application's Basic credentials, the configured organization, and the x-external-audit-issuer / x-external-audit-subject headers from getAuditSubject(request). A request without an Audit subject receives 401 and is never forwarded. To bring anonymous visitors to a declared Agent, add caller: "anonymous_visitor": each call also carries x-external-caller-kind: anonymous_visitor and never x-external-ci-user-id, so the backend runs only the Agent's own bindings and never the application's service authority. An Audit subject with mappedUserId is refused with 401 for a visitor caller. Forwarding covers context, knowledge, agents, conversations (continue and history), skills, workflows, and runs/*. Reconstruct the public origin from x-forwarded-host / x-forwarded-proto. Compose a Fixed workflow route with createFixedWorkflowRoute: it rewrites that public origin, authorizes getEntityContext plus requiredScopes, stamps x-organization-id from the session, POSTs /application/v1/workflows/:id/runs, and returns { runId }. Configure exactly one server-owned target: workflowId for a workflow UUID or workflowFqn for its stable fully qualified name. The route rejects either field in browser input and encodes the configured target as one URL segment. Override validate, idempotencyKey, authorize, or getAccessToken when the application needs different policy. The deprecated ./express entry re-exports only createGatewayExpressMiddleware from ./runtime/express.

Call CI as a member your server signed in

A server that signs members in to CI itself (for example through POST /auth/otp/verify) holds each member's CI session token. CI application APIs do not accept that session token. Exchange it for a short-lived member access token with exchangeMemberSession, then call CI through a delegated createServerClient:

import {
  createServerClient,
  exchangeMemberSession,
  MemberSessionExchangeError
} from "@collective-intelligence/external-apps-server-sdk"

const { accessToken, expiresAt } = await exchangeMemberSession({
  credentials: { clientId, clientSecret },
  organizationId,
  scopes: ["context:read", "knowledge:write"],
  sessionToken: member.ciSessionToken,
  tenantUrl: "https://ci.example.com"
})
const ci = createServerClient({
  authentication: { getAccessToken: () => accessToken },
  tenantUrl: "https://ci.example.com"
})
await ci.context.get({ organizationId })

Only a confidential application can exchange, with its client secret. CI applies the same checks as the browser login: the session is live, the member is not banned and belongs to the Organization, the application is enabled and allowed in that Organization, and the requested scopes are a non-empty subset of the application's scopes. The token acts as the member (detached_user), lasts 300 seconds, has no refresh token, and stops working when the member's CI session ends. expiresAt is counted from before the request and set 30 seconds early, so exchange again once it passes; never send either token to the browser.

The request and its response body are bounded by timeoutMs (10 seconds by default); a CI that stops answering ends in temporarily_unavailable. Pass signal to cancel: a cancelled exchange rejects with the signal's reason, never with a retryable MemberSessionExchangeError.

A refusal throws MemberSessionExchangeError. code is CI's OAuth error: invalid_grant means the CI session ended or the member cannot act in the Organization, so sign the member in again. invalid_client, unauthorized_client, invalid_scope and invalid_target point at the application's credentials, type, scopes or allowed Organizations. temporarily_unavailable is retryable. On the wire this is the RFC 8693 grant urn:ietf:params:oauth:grant-type:token-exchange on POST /oauth/token, with subject_token_type urn:collective-intelligence:params:oauth:token-type:session_token, scope and organization_id.

Reuse Nest operations and local AI tools

Use Node 22+ with ESM for the AI SDK 7 adapter. The inspected and tested baseline on 6 October 2026 is [email protected], @ai-sdk/[email protected], and Nest 11.2.4, with Zod 4.4.3 in workspace source checks and the Nest demo, and 4.6.5 in the installed AI consumer; recheck the stable AI patch when installing. Declared optional peer ranges are Nest >=10 <12, AI >=7.0.128 <8, provider-utils >=5.0.54 <6, and Zod >=4 <5. A peer range is not proof of every version combination. Install only the peers required by the subpaths you use. Nest applications also need their HTTP platform adapter, reflect-metadata, and rxjs.

Workspace SDK, backend, and Nest demo dependencies align on [email protected]. Bun resolves separate Drizzle and AI provider-utils installations for different peer versions; aligning Zod preserves their shared database and schema type identities. The installed AI consumer continues to verify [email protected] in one peer context.

  • /runtime/nestjs exports bindNestOperation, GatewayNestModule, and createGatewayNestHandler. It composes supplied application providers over the existing Fetch kernel.
  • /tools/ai-sdk exports asynchronous defineAiSdkOperation. It imports a supplied local tool; it does not generate tools or execute model generation.

The package root and /gateway do not load these optional peers. Use public subpaths rather than private implementation imports. For runnable Nest DI, caller verification, manifests, and CI Connection/binding setup, follow the Nest demo.

Bind an existing Nest callable

bindNestOperation({ declaration, normalize, authorize, execute }) returns a Gateway Operation. All three callbacks are required. declaration contains the same metadata and portable input/output projections as defineOperation; normalize uses your native schema, authorize receives the normalized input and verified invocation context, and execute calls your existing injected service. The invocation also carries signal and an optional validated idempotencyKey. Bind instance methods through a closure to retain this.

import { bindNestOperation } from "@collective-intelligence/external-apps-server-sdk/runtime/nestjs"

const operation = bindNestOperation({
  declaration: greetingDeclaration,
  normalize: ({ input }) => greetingInput.parse(input),
  authorize: ({ input, context }) => permissions.canGreet(context, input),
  execute: ({ input, signal }) => greetings.greet(input, { signal })
})

greetingDeclaration, greetingInput, permissions, and greetings are application-owned dependencies; the demo supplies a concrete composition. The binding does not inherit controller guards, middleware, pipes, interceptors, or serializers. Reuse business policy explicitly. Connection admission is followed by application/resource authorization; an External Audit Subject does not supply an application user or access rights.

Reuse an existing AI SDK tool

This complete tool binding demonstrates native defaults/transforms, a validated context factory, an explicit missing-output override, and business admission. The supplied permission service is the application integration point.

import { defineAiSdkOperation } from "@collective-intelligence/external-apps-server-sdk/tools/ai-sdk"
import type { GatewayInvocationContext } from "@collective-intelligence/external-apps-server-sdk/auth"
import { tool } from "ai"
import { z } from "zod"

export async function bindGreetingTool(permissions: {
  canGreet(context: GatewayInvocationContext, name: string): Promise<boolean>
}) {
  const existing = tool({
    description: "Greet an authorized name",
    inputSchema: z.object({
      name: z.string().transform((value) => value.trim()),
      prefix: z.string().default("Hello")
    }),
    contextSchema: z.object({ organizationId: z.string().min(1) }),
    execute: async ({ name, prefix }, options) => ({
      greeting: `${prefix} ${name}`,
      organizationId: options.context.organizationId
    })
  })
  return await defineAiSdkOperation({
    id: "greet",
    title: "Greet",
    version: "1.0.0",
    tool: existing,
    effects: "read",
    idempotency: "safe",
    retry: { mode: "never" },
    authorization: {
      organization: "required",
      delegatedSubject: "allowed",
      principalKinds: ["service_principal"],
      scopes: ["greetings:read"]
    },
    execution: { mode: "synchronous", timeoutMs: 1000 },
    limits: { maxRequestBytes: 1024, maxResponseBytes: 1024 },
    authorize: ({ input, context }) =>
      permissions.canGreet(context, input.name),
    context: ({ context }) => ({ organizationId: context.organizationId }),
    output: {
      dialect: "https://json-schema.org/draft/2020-12/schema",
      fidelity: "exact",
      document: {
        type: "object",
        additionalProperties: false,
        properties: {
          greeting: { type: "string" },
          organizationId: { type: "string" }
        },
        required: ["greeting", "organizationId"]
      }
    }
  })
}

Await the operation before putting it in defineOperationDomain and defineGateway, then mount createGatewayKernel({ gateway, verifyCaller }) through GatewayNestModule.registerAsync({ imports, inject, useFactory }). Keep handlers, runtime context, secrets, and DI instances out of the manifest. Configure the trusted CI issuer, audience, atomic replay store, live Connection grant check, and accepted operation binding before invoking it. The demo explains those prerequisites and the existing administrative setup seams.

Admission requires a local execute callback and one bounded JSON result. Provider/client-only tools, streaming/preliminary results, generation lifecycle callbacks (onInputStart, onInputDelta, onInputAvailable), approval needs, and toModelOutput are unsupported. Registration rejects unsupported tool features; a streamed/non-JSON execution result produces a bounded capability diagnostic. A context-dependent description needs a fixed description override. Missing input/output projections prevent publication.

The adapter projects supported Zod 4, Standard JSON Schema, and AI JSON Schema inputs/outputs into Draft2020-12. Native input parsing still supplies defaults and transforms before authorization and execution. input and output overrides are portable PackageSchemaProjection values for schema gaps; input must satisfy ai_function_input, output must satisfy runtime_json. Nested true/false subschemas are preserved under supported keywords; projection roots remain objects, and AI-input profile restrictions and native validation still apply. Do not overstate refinements or transforms that JSON Schema cannot encode. Overrides retain native input validation. Output is checked against its published projection and byte limit, not passed through a native output transform. Unknown/unregistered format constraints and unsupported conversions require a supported explicit projection; do not silence them with an empty schema. See portable schemas.

A tool with context needs an application-owned context(request) factory and native validator, from tool.contextSchema or the adapter's contextSchema option. Construct context from the verified caller and authorized resources. The factory result is validated before execution; invalid context never executes the tool. The adapter supplies toolCallId from CI's correlation ID, abortSignal from the invocation, and messages: []. It cannot reconstruct original model messages or approval authority. Cancellation is cooperative; ignored signals can leave work running and cannot roll back writes. Arbitrary exceptions remain private; use GatewayOperationError from /gateway only for deliberately safe caller-facing failures.

For multi-step CV work, use application-owned start/status bindings rather than waiting for a workflow in this bounded tool adapter. The CV integration guide shows distinct receipts and final results, independent resource authorization, and application-owned execution, recovery, failure, and duplicate protection.

Workflow results

Workflow invocation returns runId. A successful declared-output Workflow run read through runs.get has result: { nativeStatus: "completed", output }; the completed event from runs.stream carries that same object in data. CI reads the validated, persisted Workflow output snapshot. The Gateway Tool's response becomes public only through the Workflow's explicit outputContract bindings and schema; selecting a Tool alone does not declare a Workflow result. See the binding example and lifecycle.

The native worker finishes and persists output independently of the browser. External terminal publication stays lazy on the next authorized run read, with status, result and completion event committed together as an immutable receipt. Existing terminal results and cancellation decisions survive repeated reads. Owner-user/kind, Organization, Application and external audit-subject checks still apply to both snapshots and events.

If reading fails, observe the original runId again; do not resubmit the business command to retrieve its result. The browser SDK's runs.follow returns an event page, propagates read failures, and does not retry them automatically. Missing promised output is a technical invariant defect, not a normal null-output or partial-success terminal result. There is no historical terminal-run backfill or compatibility mode for this contract.

Keep analysis history and candidate results in Knowledge reads. Send a matching completion notification only after the matching results are actually available in Knowledge, even if the command run has already completed.

Agent runs and conversations

agents.invoke({ agentId, input }) starts one Agent run and returns { runId }. Read it with runs.get (a ServerRun; a succeeded Agent run's result is a ServerAgentRunResult, { message, output? }) or page its events with runs.stream. message is the Agent's normal final answer. output is the structured Agent Output the run saved, already valid against the Agent's declared output schema; it is absent when the run saved none, such as a clarifying question. Reading a run again returns the same saved result.

For a multi-turn chat, use conversations instead. The first message starts a conversation; each later message continues it:

const ci = runAsApplication({
  auditSubject: { issuer: "apito.it", subjectId: memberId },
  credentials,
  tenantUrl
})

const first = await ci.conversations.continue({
  agentId,
  input: "Find suppliers of recycled packaging",
  organizationId
})
// first.value.result: { conversationId, runId }

const next = await ci.conversations.continue({
  conversationId,
  input: "Tell me more about the second one",
  organizationId
})

const history = await ci.conversations.history({
  conversationId,
  organizationId
})
// history.value.result: { agentId, conversationId, createdAt, turns }

Each turn is an ordinary Agent run: follow it with runs.get / runs.stream using its runId. The run of a continued turn receives the earlier turns as the model's own conversation history: each earlier message and answer, plus any Agent Output that turn saved, before the new message. Only turns that succeeded before the new turn started count. A turn is { runId, status, input, message, output?, createdAt, completedAt }: message is the Agent's final answer once the run succeeded, otherwise null; output is present when the run saved an Agent Output.

  • A conversation belongs to the application, organization and Audit subject that started it. Another subject (or a call without that subject) gets not_found for both continue and history.
  • Wait until the previous turn finished: continuing while it still runs returns conflict.
  • idempotencyKey (at most 128 characters; generated when omitted) makes a retried message replay the same turn instead of adding one. Use each key for one message only; reusing it for a different message or conversation returns conflict.
  • The ai:invoke scope gates conversations.
  • The Agent's current audience gates every turn and every history read. When an administrator removes the caller from the audience, the same caller gets not_found for its own conversations too.
  • For an anonymous visitor, pass callerKind: "anonymous_visitor" with the visitor's auditSubject (no mappedUserId). Only a declared Agent with audience: { anonymous: true } that lists this application admits it. The visitor's turns run with the Agent's own bindings under an anonymous visitor subject, never with the application's scopes. Visitors are separate owners: one visitor never reads another visitor's conversation.

Messages

messages.send sends one message through a messaging connection of the Organization, for example a Telegram bot or a WhatsApp Business Account. The application needs the messaging:send scope, and an administrator must grant the connection to the application on its administration page.

const sent = await ci.messages.send({
  connectionId,
  content: { kind: "text", text: "Your order is ready." },
  idempotencyKey: `order-${orderId}-ready`,
  organizationId,
  target: { conversation, mode: "continue" }
})
// sent.value.result: { conversation, messageId }
  • target continues a conversation with the Conversation Reference of an earlier message ({ mode: "continue", conversation }), or starts one with the recipient fields of the vendor ({ mode: "start_new", recipient }).
  • content is a text, or { kind: "voice", text, voice } for a text that CI speaks.
  • sender holds the sender fields of the vendor, for example { phoneNumberId }. It is required when the account has more than one sender. service names the vendor service when the connection has more than one.
  • The result is the Message Receipt: the message id of the vendor and the Conversation Reference that continues the conversation. It means that the vendor accepted the message, not that a person received it.
  • idempotencyKey (at most 256 characters; generated when omitted) makes a repeat safe. A repeat with the same key and content returns the first outcome and sends nothing. A repeat with the same key and other content returns conflict. Use your own key when your server can repeat a send after a crash.
  • CI sends once and never retries a failed send. A failed send returns message_send_failed with a sendErrorCode, for example window_closed, recipient_unreachable, template_required or transport. The same key returns the same failure: use a new key to send again.
  • send_outcome_unknown means that the first send with the key has no recorded outcome: it still runs, or CI stopped before it recorded the outcome. CI does not send again for the key. Repeat the call later; if the answer stays the same, check with the vendor before you send with a new key.
  • not_found means that the connection does not exist or is not granted to the application. rate_limited (HTTP 429) means that the connection reached its rate limit; it is retryable, and the client repeats it with the same key.

Application users and roles

Use ci.users and ci.roles with a delegated user token or service credentials. CI checks the caller's current users.create and assignRoles permissions. These methods replace the former ci.members API.

const created = await ci.users.createOrGet({
  email: "[email protected]",
  name: "Rossi Srl",
  organizationId,
  roles: ["patient"],
  serverRole: "onboarding" // Optional for service calls; omission uses the credential default.
})
// On success: created.value.result = { userId, email, name, created, kind }
// kind is "member" or "external_app_user".
  • createOrGet reuses the person's auth identity. A new app user has no Organization membership. An existing member remains a member.
  • checkEmail({ organizationId, email }) asks whether createOrGet would accept the email, without creating anything. The result is { usable: true } or { usable: false, reason }, with user_unavailable or target_inactive as createOrGet would refuse. An unknown email is usable. It needs service credentials and users.create.
  • get({ organizationId, userId }) and list({ organizationId, page?, pageSize? }) read users accessible to this app. A list result contains items.
  • deactivate({ organizationId, userId }) ends access to the calling app for an app user it may deactivate; it does not remove Organization membership.
  • roles.list({ organizationId }) returns items with role id and key. roles.assign({ organizationId, userId, roles }) and roles.revoke(...) change this app's assignments within the caller's assignRoles permission. They cannot write Organization Roles, Groups, or another app's roles.
  • Pass serverRole on a service request to select an allowed Server Role via x-external-server-role. It applies only to that request; credentials and subsequent calls retain their default. It does not impersonate a user.
  • Refusals preserve the structured error, including a reason such as role_not_assignable. Non-retryable refusals are returned without retrying. CI records the actual user, or application and Server Role, as the actor.

Durable files

A confidential application stores a file that a workflow run reads later, such as the PDF of a signed form. The file is a private stored file of the Organization the credentials were authorized for, with no expiry.

const uploaded = await ci.files.upload({
  contentBase64: pdf.toString("base64"),
  contentType: "application/pdf",
  fileName: "application.pdf",
  organizationId,
  sizeBytes: pdf.byteLength
})
// uploaded.value.result: { storageFileId }

await ci.workflows.invoke({
  input: { document: { storageFileId: uploaded.value.result.storageFileId } },
  organizationId,
  workflowFqn
})
  • contentType is application/pdf, image/jpeg or image/png, and the file holds 1 byte to 10 MiB. sizeBytes must match the decoded content. Other files are refused with invalid_request.
  • A workflow input can name the storageFileId; the nodes of a run of the same Organization read the file through their stored file access. The file has no public URL, and another Organization cannot read it.
  • attachments.upload is different: its file belongs to one run and expires after 24 hours.
  • It needs the ai:attachments scope. Only service credentials may call it: a delegated user token or an anonymous visitor gets operation_not_allowed.

Entity File fields

A File field of an entity schema takes a different flow: the file becomes a File value of the entity, not a stored file a workflow reads. resources.createUpload asks CI for a single-use upload slot, the server PUTs the bytes straight to storage, and the entity write through CI's resources API names the slot's uploadId as { upload } for that File field key:

const slot = await ci.resources.createUpload({
  field: "memberLogo",
  filename: "logo.png",
  mimeType: "image/png",
  organizationId,
  schemaId: "apito.vetrina.schemas.member-company"
})
if (slot.ok) {
  const { headers, method, uploadId, url } = slot.value.result
  await fetch(url, { body: logo, headers, method })
  // The entity write then sends { memberLogo: { upload: uploadId } }.
}
  • Two credentials can ask for a slot, as for entity writes: the member's delegated token (exchangeMemberSession), writing as that member; or the application's service credential, for schemas declared by the application's own package only, writing as the Organization default service principal. schemaId is the schema id or FQN.
  • A refusal resolves { ok: false, error } with CI's correlationId and a code; upload_not_supported means the Organization has no usable default storage. Only retryable failures are retried.
  • Each attempt, request and response body, is bounded by timeoutMs (10 seconds by default); a CI that stops answering ends in a retryable unavailable. signal cancels: a cancelled request rejects with the signal's reason.
  • The PUT is the caller's: send exactly headers to url with method. No file bytes pass through CI's API. A slot is single use: the first successful write consumes it; request a new one to retry.
  • The write form of a File field key is { upload } to add the uploaded file, { id } (or a File value read before) to keep a file, an ordered array for a multiple field, or null to remove every file. Omit the key to keep its files.
  • /effect exports the Effect schemas EntityFileValue (a File value as CI reads return it), EntityFileWrite (one file of a write) and EntityFileFieldWrite (the write form of a File field key). Their types equal the client SDK types of the same name. They validate CI entity values; a portable resource form has no File field, so projectResourceSchema refuses them.

Records a user acts for

A schema declares its accounts field to name the users that act for each of its records (see the Knowledge User fields reference). CI stores no other link between a user and a record, and offers no operation that lists or changes one: write the accounts field as an administrator, with the workflow account step or with the Default Server Role credential. A policy reads the field as resource path ["data", "accounts"] and the caller as subject path ["user", "id"].

  • A delegated user's /application/v1/resources and knowledge_* calls, and the Agents, Skills and workflows the user runs, decide under the user's Knowledge policy subject. CI sets subject.access: "read" for gets, lists, searches, facets, autocomplete and retrieval, "write" for creates, updates and deletes. The operation sets it; a caller cannot. A policy that does not test access applies to reads and writes alike.
  • To let member-role users write only their own companies but read every company (for example through member-run Agents), declare a deny filter policy on the company schema: subject match user.role equals member and access equals write, rule "subject.user.id not in resource.data.accounts". Updates and deletes of any other company, and creates, then fail with forbidden; gets, lists and searches still return it. Without the access clause the same policy also hides other companies from reads. Other roles and service credentials are unaffected.
  • The policy binds an execution only while it acts as the member. An Agent or Skill tool bound to a fixed user, or a workflow that runs as another user or a service principal, decides as that principal, not as the member who invoked it. Do not give Agents or workflows that members can reach a fixed-user or run-as identity with wider Knowledge access.

Gateway registry and orchestration

Build one registry with defineGateway. Its knowledge, datasets, and operationDomains arrays are the complete allowlist projected into discovery. defineGateway snapshots those declarations, derives the deterministic manifest and resolves the executable binding for each incoming route.

Pass that registry and one machine caller verifier to createGatewayKernel. The kernel owns discovery, connection enrollment, authentication, authorization, bounded JSON handling and dispatch. A runtime adapter then lifts the same Fetch Request/Response seam into the host framework. Application routes can reuse the same CRUD resource with their own user-session policy.

const gateway = defineGateway({
  id: "crm",
  version: "1.0.0",
  knowledge: [{ resource: leadResource, access: "read_write" }],
  datasets: [leadPipeline],
  operationDomains: [crmOperations]
})

const kernel = createGatewayKernel({
  gateway,
  verifyCaller: connectionAuth.verifyCaller,
  verifySetupCredential: connectionAuth.verifySetupCredential,
  enrollConnection: connectionAuth.enrollConnection
})

This package exposes Knowledge, Dataset, and declared operation tools through the Gateway protocol. An MCP server or MCP transport is outside this SDK.

Enroll and verify connections

createGatewayConnectionAuthentication composes a durable connection-grant store, a replay store, trusted CI issuers, and a server-only setup credential. Wire its three returned functions into the kernel as shown above.

const connectionAuth = createGatewayConnectionAuthentication({
  audience: "https://crm.example/gateway",
  trustedIssuers: [
    { issuer: "https://ci.example", tenantUrl: "https://ci.example" }
  ],
  grants: connectionGrantStore,
  replayStore,
  setupCredentials: loadActiveSetupCredentials
})

CI starts enrollment with a signed discovery token and the configured setup credential. The verifier validates the token signature, issuer, audience, connection, organization, capability binding, scopes, expiry and unique jti, then confirms the connection with CI before persisting the grant. Normal calls must match an enrolled grant and repeat that live validation. Store grants and replay identifiers durably and atomically when the application runs more than one process. createInMemoryReplayStore is suitable only for a single-process fixture. The SDK verifies CI signatures; it does not issue caller tokens.

Package-managed enrollment

For a declared Gateway, configure /auth's createGatewayPackageEnrollment with the exact audience, explicitly trusted CI issuers, a local allowedPackages list of { organizationId, packageFqn, gatewayFqn } tuples, and an atomic GatewayPackageEnrollmentStore. Pass its enrollPackageConnection callback to createGatewayKernel alongside the normal invocation verifier. The kernel supplies its runtime manifest hash to the callback; do not substitute a hash from request input.

POST /v1/connections/package-enroll accepts a narrow signed Bearer token and returns { enrolled: true, manifest }. This route requires no setup credential. The token has purpose package_gateway_enrollment, binding connection:enroll, scope gateway:enroll, an exact audience, an issued-at time and a lifetime of at most 300 seconds. Its org_id, package_installation_id, package_fqn, gateway_fqn, installation_id (the Connection ID), manifest_hash and jti are required. Configured keys, local package policy and the complete runtime manifest hash must agree. The verifier also checks live CI enrollment authority before storage, with bounded requests and no redirects. Enrollment tokens never authorize operation invocation.

makeGatewayPackageEnrollmentStore from /persistence/drizzle commits nonce consumption and the complete grant in one PostgreSQL transaction. It is also available as makePlatformPersistence(...).gateway.enrollment. App-owned migrations must add nullable text columns package_installation_id, package_fqn, gateway_fqn, audience, and manifest_hash to gateway_connections before using this managed route. Legacy grant has/put/remove works with the original three-column table. The SDK executes no implicit migration.

The Nest server integration supplies configured trust and allowed identities; exact deployment uses this route without broad credentials reaching the server.

Concurrent delivery of one token admits one request; a later grant write failure rolls back nonce consumption. Retry a lost acknowledgement with a fresh signed token for the same owning installation and Connection. A different package owner cannot replace that grant. Existing setup enrollment remains available through enrollConnection; normal calls still require an enrolled grant, live CI authority, operation scopes, replay protection and application authorization.

Bounded custom operations

Define each operation once and include it in a named operation domain. CI admits only explicitly selected operations from the accepted connection revision. Its generic tool input uses the existing ai-function-input schema profile: an object root with supported keywords. Incompatible inputs are rejected at admission; their schemas are not rewritten. Declared JSON output may be an object, array or scalar and is validated before returning a successful result.

import {
  defineOperation,
  defineOperationDomain
} from "@collective-intelligence/external-apps-server-sdk/gateway"

const quote = defineOperation<{ quantity: number }, { total: number }>({
  id: "quote",
  version: "1.0.0",
  title: "Quote",
  description: "Calculate a quote for an opportunity.",
  input: {
    dialect: "https://json-schema.org/draft/2020-12/schema",
    fidelity: "exact",
    document: {
      type: "object",
      properties: { quantity: { type: "number", minimum: 1 } },
      required: ["quantity"],
      additionalProperties: false
    }
  },
  output: {
    dialect: "https://json-schema.org/draft/2020-12/schema",
    fidelity: "exact",
    document: {
      type: "object",
      properties: { total: { type: "number" } },
      required: ["total"],
      additionalProperties: false
    }
  },
  effects: "read",
  idempotency: "safe",
  retry: { mode: "never" },
  authorization: {
    organization: "required",
    principalKinds: ["service_principal"],
    delegatedSubject: "forbidden",
    scopes: ["quotes:read"]
  },
  execution: { mode: "synchronous", timeoutMs: 5000 },
  limits: { maxRequestBytes: 4096, maxResponseBytes: 4096 },
  handler: async ({ input }) => ({ total: input.quantity * 42 })
})

const crm = defineOperationDomain({
  id: "crm",
  title: "CRM",
  operations: [quote]
})
// Include crm in defineGateway({ ...definition, operationDomains: [crm] }).

Mutations that require replay protection declare idempotency: "requires_key". The host must atomically associate the key with its mutation and original result in its own database. CI persists its invocation receipt and only permits retries under the declared policy; that receipt cannot replace application-side replay protection after an uncertain network outcome. The declared timeout bounds CI's whole invocation attempt sequence, including caller authentication and retries.

Identifiers

Import uuidV7 from the package root when an application needs a portable, time-ordered UUID default. It uses the host's Web Crypto implementation and does not load any optional framework peer.

External application modules

ExternalAppSubmodule<T> validates an application-owned module with a required gateway declaration. That nested declaration must provide knowledge, a non-empty datasets list, or a non-empty operations list; application-owned fields such as resource and conversion remain alongside it. Pass the factory's inferred return type so those additional fields remain strongly typed. ExternalAppModule<Submodules> validates the named submodule map without widening those submodules:

import type {
  ExternalAppModule,
  ExternalAppSubmodule
} from "@collective-intelligence/external-apps-server-sdk/gateway"

type LeadsSubmodule = ExternalAppSubmodule<
  ReturnType<typeof makeCrmLeadsSubmodule>
>
type OpportunitiesSubmodule = ExternalAppSubmodule<
  ReturnType<typeof makeCrmOpportunitiesSubmodule>
>

type CrmModule = ExternalAppModule<{
  leads: LeadsSubmodule
  opportunities: OpportunitiesSubmodule
}>

Effect authoring

Import Effect APIs from /effect; they use the required effect peer. The executable consumer is tested with [email protected]. The package root is a Vanilla Facade over the same Effect Core, so it also loads effect. The package root and /effect share one built chunk: a class that both export, such as MemberSessionExchangeError, has one identity in the application.

/effect gives one service for each client, with a layer and a layerConfig that reads Effect Config:

  • ServerClient: layerServerClient(config), layerServerClientConfig(config), and, for the Confidential Application identity, layerApplicationServerClient(config) and layerApplicationServerClientConfig(config). executeResult(operationId, request) keeps the CI error envelope as a value; execute fails with CiError. A transport failure fails with CiTransportError. sendMessage and createUpload keep the refusal of CI as a value.
  • Without a sleep option, the retry backoff of ServerClient is Effect.sleep on the Clock: TestClock drives it, and an interruption stops it. With sleep, the client waits on that function.
  • The layer…Config constructors read the client secret with Config.Redacted: a Config<string> secret does not type check. The request unwraps the secret where it sends it. layerServerClient and layerApplicationServerClient take a string or a Redacted secret.
  • CiEntities: layerCiEntities(options) and layerCiEntitiesConfig. executeResult keeps each failure as a value of its ResourceResult; execute fails with CiError or CiTransportError. A CiEntityBinding holds the definition, its schemaFqn and its protocol: make it once with createEntityProtocol(definition) of @collective-intelligence/external-apps-client-sdk/resources.
  • exchangeMemberSession(request) is an Effect that fails with MemberSessionExchangeError, a Schema.TaggedError. It is the class of the package root.
import * as Effect from "effect/Effect"
import {
  layerServerClient,
  ServerClient
} from "@collective-intelligence/external-apps-server-sdk/effect"

const program = ServerClient.use((client) =>
  client.execute("context_get", { organizationId })
).pipe(
  Effect.catchTag("CiError", (error) => Effect.succeed(error.code)),
  Effect.provide(
    layerServerClient({
      authentication: { getAccessToken: () => memberToken },
      tenantUrl: "https://tenant.example"
    })
  )
)

DateTimeString and UuidV7 provide reusable wire schemas; jsonSchema projects an Effect schema into the JSON Schema document accepted by gateway resource and operation definitions. The projection rejects unknown properties for Schema.Struct objects while preserving the value constraints of open Schema.Record objects.

makeEffectCrudResource acquires the supplied repository Effect once when the composition program runs. It returns the same portable CrudResource used by application HTTP and gateway knowledge bindings. Its acquisition error and service requirements remain in the Effect type; provide the application's Layer and keep its scope open for the lifetime of the handlers.

import * as Effect from "effect/Effect"
import * as Schema from "effect/Schema"
import {
  createResourceEffectHandler,
  DateTimeString,
  jsonSchema,
  makeEffectCrudResource,
  UuidV7
} from "@collective-intelligence/external-apps-server-sdk/effect"

const leadSchema = Schema.Struct({
  createdAt: DateTimeString,
  id: UuidV7,
  name: Schema.NonEmptyString
})
const leadJsonSchema = jsonSchema(leadSchema)

const application = Effect.gen(function* application() {
  const resource = yield* makeEffectCrudResource({
    definition: leadDefinition,
    authorization: leadAuthorization,
    repository: leadRepositoryEffect
  })
  const handle = createResourceEffectHandler({
    resource,
    basePath: "/api/leads",
    authorize: authorizeApplicationRequest
  })
  return { resource, handle }
})

Here leadRepositoryEffect acquires the host's standard repository from its Layer; authorizeApplicationRequest returns an Effect containing true or an explicit denial Response. Typed policy failures and service requirements remain visible to the host. Bind the returned resource to defineGateway and lift its Fetch kernel with createGatewayEffectHandler. These adapters use the same validation, CRUD behavior, SQL and authorization boundaries as the native Fetch handlers.

Already-aborted requests never start host work. Live request aborts interrupt the Effect fiber, and fiber interruption aborts the forwarded Request signal. Interruption waits for finalizers within the authorization Effect (including its explicitly scoped resources) before the handler exits. A standard database operation already submitted to PostgreSQL may still commit; cancellation does not promise SQL rollback. The host owns database and server shutdown through its Layer/scope.

test/consumer/effect/run.ts is an executable example using a Layer-owned PostgreSQL fixture, one Drizzle repository, and actual application/gateway HTTP requests. bun run test:pack validates it from the published tarball and removes its owned database and server afterward.

NestJS authoring

Import GatewayNestModule and CrudResourceNestModule from /runtime/nestjs. They create native Nest dynamic modules, controllers and factory providers. Use ordinary Nest imports, inject, and useFactory options; asynchronous providers are supported. The host's module owns and exports one CRUD resource, and both registrations inject that same resource. No global registry is used.

import type { CrudResource } from "@collective-intelligence/external-apps-server-sdk/resources"
import {
  CrudResourceNestModule,
  GatewayNestModule
} from "@collective-intelligence/external-apps-server-sdk/runtime/nestjs"

const applicationRoutes = CrudResourceNestModule.registerAsync({
  imports: [LeadsModule],
  inject: [LEAD_RESOURCE],
  path: "/api/leads",
  useFactory: (resource: CrudResource) => ({
    resource,
    authorize: authorizeApplicationRequest
  })
})
const gatewayRoutes = GatewayNestModule.registerAsync({
  imports: [LeadsModule],
  inject: [LEAD_RESOURCE],
  useFactory: (resource: CrudResource) =>
    createGatewayKernel({
      gateway: defineGateway({
        ...gatewayDefinition,
        knowledge: [{ resource, access: "read_write" }]
      }),
      verifyCaller: verifyMachineCaller
    })
})

Add both returned modules to the application's @Module({ imports: [...] }). LeadsModule owns the repository, SQL driver, migrations, and LEAD_RESOURCE provider. authorizeApplicationRequest returns true or a denial Response; verifyMachineCaller supplies the separately configured gateway authority. Set a knowledge binding to access: "read" to limit its gateway operations without removing the application's own authorized CRUD capabilities.

The gateway uses its canonical /v1/* and discovery routes at the application origin; do not add a Nest global prefix to those routes. Start Nest with NestFactory.create(AppModule, { bodyParser: false }) so the SDK owns bounded JSON parsing. Other application routes can install their own body parser. The executable consumer uses Nest's Express platform; its native wildcard syntax also remains compatible with Nest 10 routing.

test/consumer/nestjs/application.ts contains the complete typed application composition. bun test src/adapters/runtime/nestjs.test.ts runs it against isolated PostgreSQL and real HTTP. bun run test:pack compiles and runs the installed Nest consumer and verifies package-root imports without Nest peers. The host owns application shutdown and database cleanup.

Next.js, Web and Express hosting

createResourceNextRouteHandlers from /next wraps the same resource HTTP handler used by other hosts. In an App Router route such as app/api/leads/[...path]/route.ts, export its method handlers:

import { createResourceNextRouteHandlers } from "@collective-intelligence/external-apps-server-sdk/next"
import { leadResource, authorizeApplicationRequest } from "../../../../server"

export const runtime = "nodejs"
export const dynamic = "force-dynamic"
export const { GET, POST, PATCH, DELETE } = createResourceNextRouteHandlers({
  resource: leadResource,
  basePath: "/api/leads",
  authorize: authorizeApplicationRequest
})

Bind that same resource to the gateway and export createGatewayNextRouteHandlers(kernel) from app/v1/[...path]/route.ts. The application policy authenticates browser requests; gateway routes retain machine authentication and their declared read or write exposure. A writable application resource can have a read-only gateway binding.

For Fetch hosts, createGatewayWebHandler(kernel) returns the canonical Gateway (Request) => Promise<Response> handler. A resource HTTP handler already uses that same Fetch shape. Express adapts either one:

app.use(
  "/api",
  createGatewayExpressMiddleware({
    handle: createResourceHttpHandler({
      resource: leadResource,
      basePath: "/leads",
      authorize: authorizeApplicationRequest
    })
  })
)
app.use("/ci", createGatewayExpressMiddleware(kernel))

Express removes its mount prefix, so these routes are /api/leads/search and /ci/v1/entities/leads/search. Mount the middleware before a body parser to let the shared handler enforce its byte limit while reading the request stream. Oversized uploads receive a bounded error without waiting for EOF; the adapter flushes that response before closing the unread connection. Client disconnects abort the forwarded request signal. The host owns server and database shutdown.

test/consumer/next builds and starts a real Next application, then exercises Next, Express and Web against one PostgreSQL resource. bun run test:pack compiles and runs that consumer from the installed package.

Shared CRUD resources

Expose a remote resource once with defineGateway({ knowledge: [{ resource, access: "read_write" }], ... }). The exposure supplies discovery and dispatch; createGatewayKernel no longer accepts a separate resource list. Use access: "read" to expose only search/get. Every exposed operation requires an explicit authorization declaration. Registrations are copied and frozen at construction. All connections see the same source records. The installed_organization marker identifies CI's destination organization; it never adds a source-row predicate.

defineCrudResource accepts a portable resource definition, a CrudRepository, and gateway authorization policies. Its validated executeCrud service is also used by createResourceHttpHandler({ resource, basePath, authorize }); the host must authorize browser requests explicitly. A browser session is not a machine caller identity. Both transports enforce declared capabilities, schemas, revision preconditions and idempotency keys.

The application routes are POST /search, POST /records, and GET|PATCH|DELETE /records/:id, relative to basePath. Search accepts nested and, or, and not filters, declared field comparisons, membership, null and text predicates, sorting, text search, and page/pageSize pagination. Page size is bounded to 100. The PostgreSQL repository adds identity fields as stable sorting tie-breakers and reads totals in the same database snapshot.

Import Drizzle persistence from @collective-intelligence/external-apps-server-sdk/persistence/drizzle. definePlatformTables() declares the SDK-owned federated-session, Gateway Connection, replay, CRUD-journal, and CRUD-idempotency tables in the PostgreSQL platform schema. Export those models from the application's Drizzle schema so the host's normal migration workflow discovers them. The SDK never creates tables at runtime.

export const platformTables = definePlatformTables()
const platformPersistence = makePlatformPersistence({
  database,
  tables: platformTables
})

const repository = new DrizzlePostgresRepository({
  database,
  table: leads,
  definition: leadDefinition,
  revisionField: "revision",
  persistence: platformPersistence.resources
})
const resource = defineCrudResource({
  definition: leadDefinition,
  repository,
  authorization: leadAuthorization
})

makePlatformPersistence() wires all SDK-owned tables to the federated-session, Gateway grant, replay, and CRUD persistence contracts in one call. Use that bundle at the application composition root so generic plumbing stays in the SDK while authentication policy and configuration remain app-owned. The same entrypoint also exports the individual store factories for hosts that need custom composition.

Effect applications may import DrizzlePostgres, layerFromDatabase, and layerNodePostgres from @collective-intelligence/external-apps-server-sdk/effect. The Node PostgreSQL layer owns the pool lifetime and accepts an explicit migrate hook. It loads the optional pg and drizzle-orm peers when it builds, so /effect loads without them. It never discovers a migration directory or runs hidden DDL. A missing peer, a bad pool config or a failed migration fails the layer with DrizzlePostgresInitializationError. The /effect declarations refer to drizzle-orm and pg types: an application that type checks with skipLibCheck: false installs drizzle-orm and @types/pg.

The generic machine resource API retains its explicit hard-delete behavior. Updates and hard deletes compare and replace the revision atomically. Stale writes return a nonretryable conflict (HTTP 409). Identity and revision fields cannot be supplied in update values. Creates persist the idempotency key, input fingerprint and original result in the same transaction as the entity and change journal. Reusing a key with different input conflicts. Committed upserts and hard-delete tombstones are available through /changes with a source-bound watermark. Generic cursor pagination and restore are not implemented by this PostgreSQL adapter and cannot be advertised. Browser entity CRUD uses the separate policy-aware port below.

When entities.deletedAtField is configured, generic get, list, scan, update and hardDelete also exclude soft-deleted rows. Repositories without entities retain their existing generic behavior.

Repository results must contain JSON data. For native driver values, provide a typed project callback, for example project: row => ({ ...row, createdAt: row.createdAt.toISOString() }). Unmapped native values are rejected, ensuring the initial result and persisted idempotent retry have identical representations.

Run bun run test:postgres for the isolated real PostgreSQL contract suite; Docker must be available. The runner owns a unique container and verifies its removal. bun run test:pack also compiles and runs installed Drizzle and Effect consumers through the published persistence entrypoints.

Authorized browser entity CRUD

defineEntityResource({ definition, repository }) binds the common page-based entity protocol to EntityRepository. Configure entities on the existing DrizzlePostgresRepository and pass repository.entities to the binding:

const repository = new DrizzlePostgresRepository({
  database,
  table: leads,
  definition: leadDefinition,
  revisionField: "revision",
  persistence: platformPersistence.resources,
  entities: {
    storeId: "customer-crm",
    deletedAtField: "deletedAt",
    policy: {
      authorizeOperation: authorizeLeadOperation,
      rowPredicate: authorizedLeadRows,
      authorizeResult: authorizeLeadDisclosure
    }
  }
})
const resource = defineEntityResource({
  definition: leadDefinition,
  repository: repository.entities
})
const handle = createResourceHttpHandler({
  basePath: "/api/entities/leads",
  resource,
  authorize: async (request) =>
    (await auth.getEntityContext(request, "leads")) ??
    new Response(null, { status: 401 })
})

auth is the existing createFederatedAuth(...) instance. Its server-only getEntityContext resolves current CI application, user, organization, scopes, the user's effective app roles and App Permissions. CI never sends Group IDs or Platform Permissions. The browser session JSON at <basePath>/session carries expiresAt and an identity with userId, organizationId, appId, kind (member or external_app_user), roles, appPermissions, email and name; it never carries the Access token. A user without a role on the app has no session and no entity access. Local Better Auth sessions do not establish CI entity authority. Browser framework adapters require this verified context instead of boolean authorization. Machine gateway calls retain their separate caller policy.

All three local policy callbacks are required. rowPredicate applies before selection, counting and writes; explicitly return undefined for shared local rows. No CI organization partition is inferred. authorizeResult checks current disclosure policy for fresh results and historical receipt snapshots, including deleted records. storeId is a stable canonical store identity, not a request URL. Migrate the nullable text or timestamp-with-timezone deletion column through the application's normal schema workflow.

Common create, update and delete require an operation key. The common definition advertises delete: true; deletion uses POST /records/:id/soft-delete. Existing HTTP DELETE retains its generic hard-delete meaning and is unavailable through the common entity binding. Missing or deleted targets return not_found; a stale live revision returns conflict.

The canonical write, new revision, existing change journal and original-result receipt commit atomically. Matching replay returns the original result even after later edits or deletion, subject to current authorization. A changed intent conflicts. The existing receipt table and retention remain in use, without a second store or automatic expiry. Synchronization and derived delivery consume the committed journal separately; later failure cannot undo the save or cause replay to write again.

Every application-owned writer, including custom operations and imports, must participate in the same revision and transactional journal authority. Raw writes that bypass it invalidate stale-write protection. Local relationship constraints remain application-owned; soft deletion does not cascade to adjacent records. Patches replace supplied top-level fields, validate the full candidate, reject empty patches and preserve omitted fields. Nonempty equal-value updates still advance the revision.

Queries use coherent page/count snapshots, application visibility before pagination, literal text search, Unicode code-point string ordering, nulls last in both directions and identity tie-breakers. Normal reads exclude tombstones. The isolated PostgreSQL suite proves these behaviors; the installed package consumer also checks common create/update/delete, original-result replay and denied replay through public entrypoints.

Dataset queries

Register a GatewayDatasetBinding beside Knowledge resources and operation domains. The binding declares one Dataset definition, authorization policy and query callback. Gateway protocol version 2 exposes the definition and bounded read-only records query:

const leadPipeline: GatewayDatasetBinding = {
  id: "lead-pipeline",
  title: "Lead pipeline",
  definition: leadPipelineDefinition,
  authorization: {
    organization: "required",
    principalKinds: ["service_principal"],
    scopes: ["datasets:read"]
  },
  query: async (input, context, { signal }) =>
    queryLeadPipeline({ input, context, signal })
}

Clients read GET /v1/datasets/:id/definition and submit POST /v1/datasets/:id/query. A query carries the admitted definition revision, source location, projection, ordering and row/byte/time bounds. The kernel rejects stale revisions, unsup