@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:
/gatewayowns deterministicdefineGatewaymanifests, explicit one-level operation domains, proposal projection, and the WebRequest/Responsepolicy kernel;/authverifies short-lived ES256 CI caller JWTs through tenant-bound JWKS, including audience, installation, organization, capability binding, expiry, and atomicjtireplay consumption; and/resourcesdeclares 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;causeholds the original failure. - When the caller aborts through
signal, the call rejects withsignal.reason. - Bad input, such as a bad
tenantUrlor a body that JSON cannot write, throwsTypeError. defineCiEntityResourcekeeps each failure as a value of itsResourceResult, withtransport_errorand itsoutcome.GatewayEffectRuntimeErroris aSchema.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:
createServerClientuses confidential service credentials for requests from the customer server to CI. Keep those credentials on the server and use client modeservice, or delegated mode with a member access token fromexchangeMemberSessionto act as a member. ItstenantUrlmust be a credential-free HTTPS origin; for local development it also accepts anhttporigin whose host is exactlylocalhost, a127.0.0.0/8address, or[::1]. Any otherhttphost is rejected before credentials are sent.- Browser-facing application routes use either CI federation from
/auth/federatedor 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/nestjsexportsbindNestOperation,GatewayNestModule, andcreateGatewayNestHandler. It composes supplied application providers over the existing Fetch kernel./tools/ai-sdkexports asynchronousdefineAiSdkOperation. 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_foundfor bothcontinueandhistory. - 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 returnsconflict.- The
ai:invokescope gates conversations. - The Agent's current audience gates every turn and every
historyread. When an administrator removes the caller from the audience, the same caller getsnot_foundfor its own conversations too. - For an anonymous visitor, pass
callerKind: "anonymous_visitor"with the visitor'sauditSubject(nomappedUserId). Only a declared Agent withaudience: { 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 }targetcontinues 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 }).contentis a text, or{ kind: "voice", text, voice }for a text that CI speaks.senderholds the sender fields of the vendor, for example{ phoneNumberId }. It is required when the account has more than one sender.servicenames 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 returnsconflict. 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_failedwith asendErrorCode, for examplewindow_closed,recipient_unreachable,template_requiredortransport. The same key returns the same failure: use a new key to send again. send_outcome_unknownmeans 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_foundmeans 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".createOrGetreuses the person's auth identity. A new app user has no Organization membership. An existing member remains a member.checkEmail({ organizationId, email })asks whethercreateOrGetwould accept the email, without creating anything. The result is{ usable: true }or{ usable: false, reason }, withuser_unavailableortarget_inactiveascreateOrGetwould refuse. An unknown email is usable. It needs service credentials andusers.create.get({ organizationId, userId })andlist({ organizationId, page?, pageSize? })read users accessible to this app. A list result containsitems.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 })returnsitemswith roleidandkey.roles.assign({ organizationId, userId, roles })androles.revoke(...)change this app's assignments within the caller'sassignRolespermission. They cannot write Organization Roles, Groups, or another app's roles.- Pass
serverRoleon a service request to select an allowed Server Role viax-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
})contentTypeisapplication/pdf,image/jpegorimage/png, and the file holds 1 byte to 10 MiB.sizeBytesmust match the decoded content. Other files are refused withinvalid_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.uploadis different: its file belongs to one run and expires after 24 hours.- It needs the
ai:attachmentsscope. Only service credentials may call it: a delegated user token or an anonymous visitor getsoperation_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.schemaIdis the schema id or FQN. - A refusal resolves
{ ok: false, error }with CI'scorrelationIdand acode;upload_not_supportedmeans the Organization has no usable default storage. Onlyretryablefailures are retried. - Each attempt, request and response body, is bounded by
timeoutMs(10 seconds by default); a CI that stops answering ends in a retryableunavailable.signalcancels: a cancelled request rejects with the signal's reason. - The PUT is the caller's: send exactly
headerstourlwithmethod. 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, ornullto remove every file. Omit the key to keep its files. /effectexports the Effect schemasEntityFileValue(a File value as CI reads return it),EntityFileWrite(one file of a write) andEntityFileFieldWrite(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, soprojectResourceSchemarefuses 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/resourcesandknowledge_*calls, and the Agents, Skills and workflows the user runs, decide under the user's Knowledge policy subject. CI setssubject.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 testaccessapplies 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 adenyfilterpolicy on the company schema: subject matchuser.roleequalsmemberandaccessequalswrite, rule "subject.user.idnot inresource.data.accounts". Updates and deletes of any other company, and creates, then fail withforbidden; gets, lists and searches still return it. Without theaccessclause 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)andlayerApplicationServerClientConfig(config).executeResult(operationId, request)keeps the CI error envelope as a value;executefails withCiError. A transport failure fails withCiTransportError.sendMessageandcreateUploadkeep the refusal of CI as a value.- Without a
sleepoption, the retry backoff ofServerClientisEffect.sleepon the Clock:TestClockdrives it, and an interruption stops it. Withsleep, the client waits on that function. - The
layer…Configconstructors read the client secret withConfig.Redacted: aConfig<string>secret does not type check. The request unwraps the secret where it sends it.layerServerClientandlayerApplicationServerClienttake a string or aRedactedsecret. CiEntities:layerCiEntities(options)andlayerCiEntitiesConfig.executeResultkeeps each failure as a value of itsResourceResult;executefails withCiErrororCiTransportError. ACiEntityBindingholds thedefinition, itsschemaFqnand itsprotocol: make it once withcreateEntityProtocol(definition)of@collective-intelligence/external-apps-client-sdk/resources.exchangeMemberSession(request)is an Effect that fails withMemberSessionExchangeError, aSchema.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
