@caltra/backend-sdk 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,68 @@
1
- # Temporary Holding Version
1
+ # Caltra backend SDK
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Define tools in your TypeScript backend and serve them to Caltra through an outbound authenticated WebSocket. Handler code and application data remain on your backend. The public client is `CaltraBackendClient`.
4
+
5
+ ## Install
6
+
7
+ Requires Node.js 24 or newer. This package is ESM.
8
+
9
+ ```sh
10
+ mkdir caltra-backend && cd caltra-backend
11
+ npm init -y
12
+ npm pkg set type=module
13
+ npm install @caltra/backend-sdk zod
14
+ npm install --save-dev typescript tsx @types/node
15
+ cp node_modules/@caltra/backend-sdk/examples/quickstart.ts quickstart.ts
16
+ ```
17
+
18
+ In Caltra, open **Backends → Add backend**. Restrict its workspace availability, then save the backend ID and one-time credential. All replicas share those credentials.
19
+
20
+ Create `backend.config.json` using your backend ID and credential:
21
+
22
+ ```json
23
+ {
24
+ "url": "wss://api.caltra.dev/backends/v1/connect",
25
+ "backendId": "REPLACE_WITH_BACKEND_UUID",
26
+ "credential": "REPLACE_WITH_BACKEND_CREDENTIAL",
27
+ "label": "example-replica-1"
28
+ }
29
+ ```
30
+
31
+ Keep the credential in your secret manager, restrict permissions on the configuration file, and exclude it from source control. The executable [quickstart](examples/quickstart.ts) validates this configuration before connecting. It defines a harmless `diagnostics.echo` tool returning a message, trusted workspace ID, and replica label.
32
+
33
+ ```sh
34
+ chmod 600 backend.config.json
35
+ npx tsc --noEmit --strict --target ES2022 --module NodeNext --moduleResolution NodeNext quickstart.ts
36
+ # Publish once when deploying new definitions.
37
+ npx tsx quickstart.ts publish backend.config.json
38
+ # Start each replica separately; Ctrl-C closes its connection.
39
+ npx tsx quickstart.ts serve backend.config.json
40
+ ```
41
+
42
+ In **Backends → Tools**, call `diagnostics.echo` with `{"message":"hello"}` in an allowed workspace. Expect the same message, that workspace's ID, and `example-replica-1`. For a hosted agent, grant the tool to its execution principal under **Access**, then ask the agent to discover and call it. Revoke the grant to deny subsequent calls, including an already discovered action ID.
43
+
44
+ ## API
45
+
46
+ ```ts
47
+ import { BackendTool, CaltraBackendClient, type ToolContext } from "@caltra/backend-sdk";
48
+ ```
49
+
50
+ - `BackendTool` takes a name, description, Zod input/output schemas, and `handler(context, inputs)`. Use JSON-Schema-compatible Zod schemas. Input and output validation happens locally and on Caltra.
51
+ - `new CaltraBackendClient({ url, backendId, credential, label, tools, maxConcurrency? })` creates a client. Default concurrency is 1; supported values are 1–100.
52
+ - `publish()` explicitly publishes definitions and returns `{ id, version, hash }`. It opens and closes its own connection. Identical current definitions reuse the version.
53
+ - `connect()` registers one replica against matching, already-published definitions. It returns `{ instanceId, manifest, heartbeatMs }` and starts transport heartbeats.
54
+ - `waitForDisconnect()` waits for that serving connection to close. The SDK does not reconnect automatically.
55
+ - `close()` closes the serving connection and waits for disconnect.
56
+
57
+ The URL must use `wss://`; `ws://` is allowed only for loopback development. Credentials belong in authentication headers, never the URL. A proxy must support WebSocket upgrades and long-lived connections.
58
+
59
+ `ToolContext` carries trusted organization, workspace, environment, principal, invocation ID and deadline. Hosted calls also include principal, session, turn and agent IDs. Tool inputs cannot replace these fields. Enforce your application's business permissions using the trusted context.
60
+
61
+
62
+ Protocol 2 always supplies `workspaceExternalId: string | null` from the authorized workspace row and `tenantUserExternalId: string | null` from the execution principal's tenant-user row. Values are exact stored IDs, including any workspace namespace prefix; Caltra does not strip or guess them. A null means that the row has no external ID, or, for the user field, that the execution is not a tenant user. Agent-identity, system and platform-user playground calls always receive a null user external ID, even when the session has a user owner or initiator. Hosted `principalId` and `principal.id` remain the Caltra tenant-principal UUID, not the tenant-user UUID or external ID. Use the environment and workspace context together with external IDs when selecting application data.
63
+
64
+ Upgrade all Caltra API nodes before starting clients from this SDK revision. The client explicitly registers `protocolVersion: 2` on the existing `/backends/v1/connect` URL; older servers reject registration and there is no silent fallback. Updated servers still accept SDK 0.1.0 / protocol 1 replicas and omit both external-ID fields entirely for them, including calls routed across API nodes. Redis forwarding preserves the legacy call shape and carries the new identity fields separately, so older API socket owners continue serving legacy clients during the server rollout. Protocol 2 requires both fields, so incomplete context from an older API node fails before handler execution during a mixed-server rollout. Protocol versions are independent of manifest versions. Publication of this SDK revision and deployment of Caltra are separate release steps.
65
+
66
+ Caltra routes each call to one available matching-manifest replica and rechecks current access at dispatch. There is no cancellation API or automatic execution retry. A timeout/disconnect can leave the outcome unknown; your handler may continue. Deduplicate business writes using your own operation key before retrying. A newer manifest can temporarily make older replicas unavailable; publish deliberately and start matching replicas.
67
+
68
+ See the [backend tools documentation](https://caltra.dev/docs/backend-tools) for grants, version rollout, context, limits and wire protocol. The `/protocol` and `/peer` exports support protocol integrations; ordinary applications need only the package root.
@@ -0,0 +1,28 @@
1
+ import { type ManifestVersion, type Registration } from "./protocol.js";
2
+ import type { ToolRegistration } from "./tool.js";
3
+ /** Publishes definitions explicitly and serves one replica on an outbound, authenticated socket. */
4
+ export declare class CaltraBackendClient {
5
+ private readonly options;
6
+ private peer?;
7
+ private registration?;
8
+ private heartbeat?;
9
+ private readonly calls;
10
+ private readonly seen;
11
+ private closed;
12
+ private readonly manifest;
13
+ constructor(options: {
14
+ url: string;
15
+ backendId: string;
16
+ credential: string;
17
+ tools: ToolRegistration[];
18
+ label: string;
19
+ maxConcurrency?: number;
20
+ });
21
+ publish(): Promise<ManifestVersion>;
22
+ connect(): Promise<Registration>;
23
+ waitForDisconnect(): Promise<void>;
24
+ close(): Promise<void>;
25
+ private open;
26
+ private scheduleHeartbeat;
27
+ private invoke;
28
+ }
package/dist/client.js ADDED
@@ -0,0 +1,105 @@
1
+ import { WebSocket } from "ws";
2
+ import { JSONRPCErrorException } from "json-rpc-2.0";
3
+ import { z } from "zod";
4
+ import { BackendProtocol, CallSchema, ManifestVersionSchema, RegistrationSchema } from "./protocol.js";
5
+ import { BackendRpcPeer } from "./peer.js";
6
+ /** Publishes definitions explicitly and serves one replica on an outbound, authenticated socket. */
7
+ export class CaltraBackendClient {
8
+ options;
9
+ peer;
10
+ registration;
11
+ heartbeat;
12
+ calls = new Set();
13
+ seen = new Set();
14
+ closed = Promise.resolve();
15
+ manifest;
16
+ constructor(options) {
17
+ this.options = options;
18
+ const url = new URL(options.url);
19
+ if (url.protocol !== "wss:" && !(url.protocol === "ws:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))
20
+ throw new Error("Use wss:// (ws:// is allowed only on loopback).");
21
+ if (url.username || url.password || url.search || url.hash)
22
+ throw new Error("Connection URL must not contain credentials, query parameters or fragments.");
23
+ z.string().uuid().parse(options.backendId);
24
+ this.manifest = options.tools.map(tool => tool.manifest);
25
+ BackendProtocol.hash(this.manifest);
26
+ }
27
+ async publish() {
28
+ const peer = await this.open();
29
+ try {
30
+ return ManifestVersionSchema.parse(await peer.request("manifests/publish", { tools: this.manifest }));
31
+ }
32
+ finally {
33
+ peer.close();
34
+ }
35
+ }
36
+ async connect() {
37
+ if (this.peer)
38
+ throw new Error("This client is already connected.");
39
+ const peer = await this.open();
40
+ this.peer = peer;
41
+ this.closed = new Promise(resolve => peer.socket.once("close", () => {
42
+ if (this.peer === peer) {
43
+ this.peer = undefined;
44
+ this.registration = undefined;
45
+ }
46
+ clearTimeout(this.heartbeat);
47
+ resolve();
48
+ }));
49
+ peer.method("tools/call", params => this.invoke(params));
50
+ try {
51
+ this.registration = RegistrationSchema.parse(await peer.request("instances/register", {
52
+ protocolVersion: 2, manifestHash: BackendProtocol.hash(this.manifest), label: this.options.label, maxConcurrency: this.options.maxConcurrency ?? 1,
53
+ }));
54
+ this.scheduleHeartbeat(peer);
55
+ return this.registration;
56
+ }
57
+ catch (error) {
58
+ peer.close();
59
+ throw error;
60
+ }
61
+ }
62
+ waitForDisconnect() { return this.closed; }
63
+ async close() { clearTimeout(this.heartbeat); this.peer?.close(); await this.closed; }
64
+ async open() {
65
+ const socket = new WebSocket(this.options.url, { headers: { authorization: `Bearer ${this.options.credential}`, "x-caltra-backend-id": this.options.backendId }, maxPayload: BackendProtocol.maxBytes, handshakeTimeout: 10000, followRedirects: false });
66
+ const peer = new BackendRpcPeer(socket);
67
+ await new Promise((resolve, reject) => { socket.once("open", resolve); socket.once("error", () => reject(new Error("Backend connection failed; check URL and credential."))); });
68
+ return peer;
69
+ }
70
+ scheduleHeartbeat(peer) {
71
+ // This timer observes a live transport; PostgreSQL lease expiry remains authoritative after a crash.
72
+ this.heartbeat = setTimeout(() => {
73
+ void peer.request("instances/heartbeat", {}, 5000).then(() => {
74
+ if (this.peer === peer && peer.socket.readyState === WebSocket.OPEN)
75
+ this.scheduleHeartbeat(peer);
76
+ }).catch(() => peer.close());
77
+ }, this.registration.heartbeatMs);
78
+ }
79
+ async invoke(params) {
80
+ const call = CallSchema.parse(params);
81
+ if (!this.registration || call.manifestVersion !== this.registration.manifest.version)
82
+ throw new JSONRPCErrorException("Manifest mismatch.", -32001);
83
+ const tool = this.options.tools.find(tool => tool.manifest.name === call.name);
84
+ if (!tool)
85
+ throw new JSONRPCErrorException("Tool not found.", -32601);
86
+ if (this.seen.has(call.invocationId))
87
+ throw new JSONRPCErrorException("Duplicate invocation.", -32002);
88
+ if (this.calls.size >= (this.options.maxConcurrency ?? 1))
89
+ throw new JSONRPCErrorException("Instance is busy.", -32003);
90
+ const remaining = Date.parse(call.deadline) - Date.now();
91
+ if (remaining <= 0)
92
+ throw new JSONRPCErrorException("Deadline expired.", -32004);
93
+ this.seen.add(call.invocationId);
94
+ if (this.seen.size > 10000)
95
+ this.seen.delete(this.seen.values().next().value);
96
+ this.calls.add(call.invocationId);
97
+ // A disconnected or timed-out call can still be running; retain its capacity until the handler finishes.
98
+ try {
99
+ return await tool.invoke({ ...call.context, invocationId: call.invocationId, deadline: new Date(call.deadline) }, call.inputs);
100
+ }
101
+ finally {
102
+ this.calls.delete(call.invocationId);
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,4 @@
1
+ export { CaltraBackendClient } from "./client.js";
2
+ export { BackendTool } from "./tool.js";
3
+ export type { ToolRegistration } from "./tool.js";
4
+ export type { ToolContext, ManifestVersion, Registration } from "./protocol.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { CaltraBackendClient } from "./client.js";
2
+ export { BackendTool } from "./tool.js";
package/dist/peer.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { WebSocket } from "ws";
2
+ /** Binds the JSON-RPC library to bounded text frames and rejects pending requests on disconnect. */
3
+ export declare class BackendRpcPeer {
4
+ readonly socket: WebSocket;
5
+ private readonly server;
6
+ private readonly client;
7
+ private readonly peer;
8
+ private inFlight;
9
+ constructor(socket: WebSocket);
10
+ method(name: string, handler: (params: unknown) => Promise<unknown>): void;
11
+ request(method: string, params: unknown, timeoutMs?: number): Promise<unknown>;
12
+ close(): void;
13
+ private send;
14
+ }
package/dist/peer.js ADDED
@@ -0,0 +1,66 @@
1
+ import { z } from "zod";
2
+ import { randomUUID } from "node:crypto";
3
+ import { JSONRPCClient, JSONRPCServer, JSONRPCServerAndClient, JSONRPCErrorException, createJSONRPCErrorResponse } from "json-rpc-2.0";
4
+ import { WebSocket } from "ws";
5
+ import { BackendProtocol } from "./protocol.js";
6
+ /** Binds the JSON-RPC library to bounded text frames and rejects pending requests on disconnect. */
7
+ export class BackendRpcPeer {
8
+ socket;
9
+ server = new JSONRPCServer({ errorListener: () => undefined });
10
+ client;
11
+ peer;
12
+ inFlight = 0;
13
+ constructor(socket) {
14
+ this.socket = socket;
15
+ this.client = new JSONRPCClient(async (request) => this.send(request), () => randomUUID());
16
+ this.peer = new JSONRPCServerAndClient(this.server, this.client, { errorListener: () => undefined });
17
+ socket.on("message", (data, binary) => {
18
+ if (binary || Buffer.byteLength(data.toString()) > BackendProtocol.maxBytes || this.inFlight >= 128) {
19
+ socket.close(1008, "Invalid frame or too many requests");
20
+ return;
21
+ }
22
+ let payload;
23
+ try {
24
+ payload = JSON.parse(data.toString());
25
+ }
26
+ catch {
27
+ void this.send(createJSONRPCErrorResponse(null, -32700, "Parse error")).catch(() => socket.terminate());
28
+ return;
29
+ }
30
+ if (Array.isArray(payload)) {
31
+ void this.send(createJSONRPCErrorResponse(null, -32600, "Batch requests are not supported.")).catch(() => socket.terminate());
32
+ return;
33
+ }
34
+ this.inFlight++;
35
+ void this.peer.receiveAndSend(payload).catch(() => socket.close(1008, "Invalid RPC message")).finally(() => this.inFlight--);
36
+ });
37
+ socket.on("error", () => this.client.rejectAllPendingRequests("Backend disconnected; invocation outcome may be unknown."));
38
+ socket.on("close", () => this.client.rejectAllPendingRequests("Backend disconnected; invocation outcome may be unknown."));
39
+ }
40
+ method(name, handler) {
41
+ this.server.addMethod(name, async (params) => {
42
+ try {
43
+ return await handler(params);
44
+ }
45
+ catch (error) {
46
+ if (error instanceof JSONRPCErrorException)
47
+ throw error;
48
+ if (error instanceof z.ZodError)
49
+ throw new JSONRPCErrorException("Invalid parameters or tool data.", -32602);
50
+ throw new JSONRPCErrorException("Request could not be completed.", -32000);
51
+ }
52
+ });
53
+ }
54
+ async request(method, params, timeoutMs = BackendProtocol.callTimeoutMs) {
55
+ return this.client.timeout(timeoutMs, id => createJSONRPCErrorResponse(id, -32015, "Backend RPC timed out; invocation outcome may be unknown. Do not automatically retry.")).request(method, params);
56
+ }
57
+ close() { this.socket.close(1000, "Closed"); }
58
+ async send(value) {
59
+ const data = JSON.stringify(value);
60
+ if (this.socket.readyState !== WebSocket.OPEN)
61
+ throw new Error("Backend connection is closed.");
62
+ if (Buffer.byteLength(data) > BackendProtocol.maxBytes || this.socket.bufferedAmount > BackendProtocol.maxBytes)
63
+ throw new Error("RPC payload or buffered data exceeds limit.");
64
+ await new Promise((resolve, reject) => this.socket.send(data, error => error ? reject(error) : resolve()));
65
+ }
66
+ }
@@ -0,0 +1,149 @@
1
+ import { z } from "zod";
2
+ export declare const ToolManifestSchema: z.ZodObject<{
3
+ name: z.ZodString;
4
+ description: z.ZodString;
5
+ inputSchema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
6
+ outputSchema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
7
+ }, z.core.$strict>;
8
+ export declare const ManifestSchema: z.ZodArray<z.ZodObject<{
9
+ name: z.ZodString;
10
+ description: z.ZodString;
11
+ inputSchema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
12
+ outputSchema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
13
+ }, z.core.$strict>>;
14
+ export declare const ManifestVersionSchema: z.ZodObject<{
15
+ id: z.ZodString;
16
+ version: z.ZodNumber;
17
+ hash: z.ZodString;
18
+ }, z.core.$strip>;
19
+ export declare const RegisterSchema: z.ZodObject<{
20
+ protocolVersion: z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>;
21
+ manifestHash: z.ZodString;
22
+ label: z.ZodString;
23
+ maxConcurrency: z.ZodNumber;
24
+ }, z.core.$strict>;
25
+ export declare const RegistrationSchema: z.ZodObject<{
26
+ instanceId: z.ZodString;
27
+ manifest: z.ZodObject<{
28
+ id: z.ZodString;
29
+ version: z.ZodNumber;
30
+ hash: z.ZodString;
31
+ }, z.core.$strip>;
32
+ heartbeatMs: z.ZodNumber;
33
+ }, z.core.$strip>;
34
+ export declare const LegacyCallContextSchema: z.ZodUnion<readonly [z.ZodObject<{
35
+ organizationId: z.ZodString;
36
+ workspaceId: z.ZodString;
37
+ environmentId: z.ZodString;
38
+ principal: z.ZodObject<{
39
+ kind: z.ZodLiteral<"platform_user">;
40
+ id: z.ZodString;
41
+ }, z.core.$strict>;
42
+ }, z.core.$strict>, z.ZodObject<{
43
+ organizationId: z.ZodString;
44
+ workspaceId: z.ZodString;
45
+ environmentId: z.ZodString;
46
+ principalId: z.ZodString;
47
+ principal: z.ZodObject<{
48
+ kind: z.ZodEnum<{
49
+ tenant_user: "tenant_user";
50
+ agent_identity: "agent_identity";
51
+ system: "system";
52
+ }>;
53
+ id: z.ZodString;
54
+ }, z.core.$strict>;
55
+ sessionId: z.ZodString;
56
+ turnId: z.ZodString;
57
+ agentIdentityId: z.ZodNullable<z.ZodString>;
58
+ agentId: z.ZodString;
59
+ }, z.core.$strict>]>;
60
+ export declare const CallContextSchema: z.ZodUnion<readonly [z.ZodObject<{
61
+ organizationId: z.ZodString;
62
+ workspaceId: z.ZodString;
63
+ environmentId: z.ZodString;
64
+ principal: z.ZodObject<{
65
+ kind: z.ZodLiteral<"platform_user">;
66
+ id: z.ZodString;
67
+ }, z.core.$strict>;
68
+ tenantUserExternalId: z.ZodNull;
69
+ workspaceExternalId: z.ZodNullable<z.ZodString>;
70
+ }, z.core.$strict>, z.ZodObject<{
71
+ organizationId: z.ZodString;
72
+ workspaceId: z.ZodString;
73
+ environmentId: z.ZodString;
74
+ principalId: z.ZodString;
75
+ principal: z.ZodObject<{
76
+ kind: z.ZodEnum<{
77
+ tenant_user: "tenant_user";
78
+ agent_identity: "agent_identity";
79
+ system: "system";
80
+ }>;
81
+ id: z.ZodString;
82
+ }, z.core.$strict>;
83
+ sessionId: z.ZodString;
84
+ turnId: z.ZodString;
85
+ agentIdentityId: z.ZodNullable<z.ZodString>;
86
+ agentId: z.ZodString;
87
+ workspaceExternalId: z.ZodNullable<z.ZodString>;
88
+ tenantUserExternalId: z.ZodNullable<z.ZodString>;
89
+ }, z.core.$strict>]>;
90
+ export declare const CallSchema: z.ZodObject<{
91
+ invocationId: z.ZodString;
92
+ manifestVersion: z.ZodNumber;
93
+ name: z.ZodString;
94
+ inputs: z.ZodUnknown;
95
+ context: z.ZodUnion<readonly [z.ZodObject<{
96
+ organizationId: z.ZodString;
97
+ workspaceId: z.ZodString;
98
+ environmentId: z.ZodString;
99
+ principal: z.ZodObject<{
100
+ kind: z.ZodLiteral<"platform_user">;
101
+ id: z.ZodString;
102
+ }, z.core.$strict>;
103
+ tenantUserExternalId: z.ZodNull;
104
+ workspaceExternalId: z.ZodNullable<z.ZodString>;
105
+ }, z.core.$strict>, z.ZodObject<{
106
+ organizationId: z.ZodString;
107
+ workspaceId: z.ZodString;
108
+ environmentId: z.ZodString;
109
+ principalId: z.ZodString;
110
+ principal: z.ZodObject<{
111
+ kind: z.ZodEnum<{
112
+ tenant_user: "tenant_user";
113
+ agent_identity: "agent_identity";
114
+ system: "system";
115
+ }>;
116
+ id: z.ZodString;
117
+ }, z.core.$strict>;
118
+ sessionId: z.ZodString;
119
+ turnId: z.ZodString;
120
+ agentIdentityId: z.ZodNullable<z.ZodString>;
121
+ agentId: z.ZodString;
122
+ workspaceExternalId: z.ZodNullable<z.ZodString>;
123
+ tenantUserExternalId: z.ZodNullable<z.ZodString>;
124
+ }, z.core.$strict>]>;
125
+ deadline: z.ZodString;
126
+ }, z.core.$strict>;
127
+ export type ToolManifest = z.infer<typeof ToolManifestSchema>;
128
+ export type ManifestVersion = z.infer<typeof ManifestVersionSchema>;
129
+ export type Registration = z.infer<typeof RegistrationSchema>;
130
+ export type ToolCall = z.infer<typeof CallSchema>;
131
+ export type LegacyToolCall = Omit<ToolCall, "context"> & {
132
+ context: z.infer<typeof LegacyCallContextSchema>;
133
+ };
134
+ export type ToolContext = z.infer<typeof CallContextSchema> & {
135
+ invocationId: string;
136
+ deadline: Date;
137
+ };
138
+ /** Defines negotiated wire contracts and a stable digest independent of JSON object key order. */
139
+ export declare class BackendProtocol {
140
+ /** Projects at the socket owner so legacy replicas never see protocol-two fields. */
141
+ static forVersion(call: ToolCall | LegacyToolCall, version: 1 | 2): ToolCall | LegacyToolCall;
142
+ static readonly path = "/backends/v1/connect";
143
+ static readonly maxBytes: number;
144
+ static readonly heartbeatMs = 10000;
145
+ static readonly leaseMs = 35000;
146
+ static readonly callTimeoutMs = 15000;
147
+ static hash(tools: ToolManifest[]): string;
148
+ private static canonical;
149
+ }
@@ -0,0 +1,71 @@
1
+ import { createHash } from "node:crypto";
2
+ import { z } from "zod";
3
+ export const ToolManifestSchema = z.object({
4
+ name: z.string().min(1).max(128).regex(/^[A-Za-z0-9_.-]+$/u),
5
+ description: z.string().max(10000),
6
+ inputSchema: z.record(z.string(), z.unknown()),
7
+ outputSchema: z.record(z.string(), z.unknown()),
8
+ }).strict();
9
+ export const ManifestSchema = z.array(ToolManifestSchema).max(100).superRefine((tools, context) => {
10
+ if (new Set(tools.map(tool => tool.name)).size !== tools.length)
11
+ context.addIssue({ code: "custom", message: "Duplicate tool names." });
12
+ });
13
+ export const ManifestVersionSchema = z.object({ id: z.string().uuid(), version: z.number().int().positive(), hash: z.string().length(64) });
14
+ export const RegisterSchema = z.object({ protocolVersion: z.union([z.literal(1), z.literal(2)]), manifestHash: z.string().length(64), label: z.string().min(1).max(100).regex(/\S/u), maxConcurrency: z.number().int().min(1).max(100) }).strict();
15
+ export const RegistrationSchema = z.object({ instanceId: z.string().uuid(), manifest: ManifestVersionSchema, heartbeatMs: z.number().int().positive() });
16
+ const ContextScope = z.object({
17
+ organizationId: z.string().uuid(), workspaceId: z.string().uuid(), environmentId: z.string().uuid(),
18
+ });
19
+ const PlatformContextSchema = z.object({
20
+ organizationId: z.string().uuid(), workspaceId: z.string().uuid(), environmentId: z.string().uuid(),
21
+ principal: z.object({ kind: z.literal("platform_user"), id: z.string().uuid() }).strict(),
22
+ }).strict();
23
+ const HostedContextSchema = ContextScope.extend({
24
+ principalId: z.string().uuid(), principal: z.object({ kind: z.enum(["tenant_user", "agent_identity", "system"]), id: z.string().uuid() }).strict(),
25
+ sessionId: z.string().uuid(), turnId: z.string().uuid(), agentIdentityId: z.string().uuid().nullable(), agentId: z.string().uuid(),
26
+ }).strict();
27
+ // Version one remains frozen: deployed 0.1.0 clients reject unknown context keys.
28
+ export const LegacyCallContextSchema = z.union([PlatformContextSchema, HostedContextSchema]);
29
+ const ExternalContext = {
30
+ workspaceExternalId: z.string().nullable(),
31
+ tenantUserExternalId: z.string().nullable(),
32
+ };
33
+ export const CallContextSchema = z.union([
34
+ PlatformContextSchema.extend({ ...ExternalContext, tenantUserExternalId: z.null() }),
35
+ HostedContextSchema.extend(ExternalContext).superRefine((context, issue) => {
36
+ if (context.principal.kind !== "tenant_user" && context.tenantUserExternalId !== null) {
37
+ issue.addIssue({ code: "custom", message: "Only tenant-user execution may carry a user external ID." });
38
+ }
39
+ }),
40
+ ]);
41
+ export const CallSchema = z.object({
42
+ invocationId: z.string().uuid(), manifestVersion: z.number().int().positive(), name: z.string(),
43
+ inputs: z.unknown(), context: CallContextSchema, deadline: z.string().datetime(),
44
+ }).strict();
45
+ /** Defines negotiated wire contracts and a stable digest independent of JSON object key order. */
46
+ export class BackendProtocol {
47
+ /** Projects at the socket owner so legacy replicas never see protocol-two fields. */
48
+ static forVersion(call, version) {
49
+ if (version === 2)
50
+ return CallSchema.parse(call);
51
+ const context = { ...call.context };
52
+ delete context.workspaceExternalId;
53
+ delete context.tenantUserExternalId;
54
+ return { ...call, context: LegacyCallContextSchema.parse(context) };
55
+ }
56
+ static path = "/backends/v1/connect";
57
+ static maxBytes = 1024 * 1024;
58
+ static heartbeatMs = 10000;
59
+ static leaseMs = 35000;
60
+ static callTimeoutMs = 15000;
61
+ static hash(tools) {
62
+ return createHash("sha256").update(JSON.stringify(this.canonical([...ManifestSchema.parse(tools)].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)))).digest("hex");
63
+ }
64
+ static canonical(value) {
65
+ if (Array.isArray(value))
66
+ return value.map(item => this.canonical(item));
67
+ if (value && typeof value === "object")
68
+ return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, item]) => [key, this.canonical(item)]));
69
+ return value;
70
+ }
71
+ }
package/dist/tool.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { z } from "zod";
2
+ import type { ToolContext, ToolManifest } from "./protocol.js";
3
+ export type ToolRegistration = {
4
+ manifest: ToolManifest;
5
+ invoke(context: ToolContext, inputs: unknown): Promise<unknown>;
6
+ };
7
+ /** Keeps executable code local while exporting the same schemas used to validate each invocation. */
8
+ export declare class BackendTool<I extends z.ZodType, O extends z.ZodType> implements ToolRegistration {
9
+ private readonly definition;
10
+ readonly manifest: ToolManifest;
11
+ constructor(definition: {
12
+ name: string;
13
+ description: string;
14
+ input: I;
15
+ output: O;
16
+ handler(context: ToolContext, inputs: z.output<I>): Promise<z.input<O>>;
17
+ });
18
+ invoke(context: ToolContext, inputs: unknown): Promise<unknown>;
19
+ }
package/dist/tool.js ADDED
@@ -0,0 +1,15 @@
1
+ import { z } from "zod";
2
+ /** Keeps executable code local while exporting the same schemas used to validate each invocation. */
3
+ export class BackendTool {
4
+ definition;
5
+ manifest;
6
+ constructor(definition) {
7
+ this.definition = definition;
8
+ this.manifest = { name: definition.name, description: definition.description,
9
+ inputSchema: z.toJSONSchema(definition.input, { io: "input" }), outputSchema: z.toJSONSchema(definition.output, { io: "output" }) };
10
+ }
11
+ async invoke(context, inputs) {
12
+ const parsed = this.definition.input.parse(inputs);
13
+ return this.definition.output.parse(await this.definition.handler(context, parsed));
14
+ }
15
+ }
@@ -0,0 +1,35 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { BackendTool, CaltraBackendClient } from "@caltra/backend-sdk";
3
+ import { z } from "zod";
4
+
5
+ /** Loads validated deployment configuration and serves a harmless tool without accessing application data. */
6
+ class BackendQuickstart {
7
+ async run(command: string, configPath: string): Promise<void> {
8
+ if (command !== "publish" && command !== "serve") throw new Error("Use publish or serve, followed by a config file path.");
9
+ const config = z.object({
10
+ url: z.url(), backendId: z.uuid(), credential: z.string().min(1), label: z.string().min(1),
11
+ }).strict().parse(JSON.parse(readFileSync(configPath, "utf8")));
12
+ const backend = new CaltraBackendClient({
13
+ ...config,
14
+ tools: [new BackendTool({
15
+ name: "diagnostics.echo",
16
+ description: "Echo a test message with the trusted workspace and serving replica.",
17
+ input: z.object({ message: z.string().min(1) }).strict(),
18
+ output: z.object({ message: z.string(), workspaceId: z.uuid(), workspaceExternalId: z.string().nullable(), tenantUserExternalId: z.string().nullable(), handledBy: z.string() }).strict(),
19
+ handler: async (context, inputs) => ({ message: inputs.message, workspaceId: context.workspaceId, workspaceExternalId: context.workspaceExternalId, tenantUserExternalId: context.tenantUserExternalId, handledBy: config.label }),
20
+ })],
21
+ });
22
+ if (command === "publish") {
23
+ process.stdout.write(`${JSON.stringify(await backend.publish())}\n`);
24
+ return;
25
+ }
26
+ process.once("SIGINT", () => { void backend.close(); });
27
+ process.once("SIGTERM", () => { void backend.close(); });
28
+ try {
29
+ process.stdout.write(`${JSON.stringify(await backend.connect())}\n`);
30
+ await backend.waitForDisconnect();
31
+ } finally { await backend.close(); }
32
+ }
33
+ }
34
+
35
+ await new BackendQuickstart().run(process.argv[2] ?? "", process.argv[3] ?? "backend.config.json");
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "@caltra/backend-sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "types": "./dist/index.d.ts",
8
+ "import": "./dist/index.js"
9
+ },
10
+ "./protocol": {
11
+ "types": "./dist/protocol.d.ts",
12
+ "import": "./dist/protocol.js"
13
+ },
14
+ "./peer": {
15
+ "types": "./dist/peer.d.ts",
16
+ "import": "./dist/peer.js"
17
+ }
18
+ },
19
+ "scripts": {
20
+ "build": "tsc -p tsconfig.json",
21
+ "typecheck": "tsc -p tsconfig.json --noEmit",
22
+ "lint": "eslint src --ext .ts",
23
+ "test": "vitest run",
24
+ "prepack": "npm run build"
25
+ },
26
+ "dependencies": {
27
+ "json-rpc-2.0": "1.8.1",
28
+ "ws": "8.22.0",
29
+ "zod": "^4.4.3",
30
+ "@types/ws": "8.18.2"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "^25.9.3",
34
+ "typescript": "^5.9.3",
35
+ "vitest": "^3.2.7"
36
+ },
37
+ "description": "TypeScript backend tools for Caltra over outbound authenticated WebSockets",
38
+ "main": "./dist/index.js",
39
+ "types": "./dist/index.d.ts",
40
+ "files": [
41
+ "dist",
42
+ "examples/quickstart.ts",
43
+ "README.md"
44
+ ],
45
+ "engines": {
46
+ "node": ">=24.0.0"
47
+ },
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/caltra-dev/caltra.git",
51
+ "directory": "packages/backend-sdk"
52
+ },
53
+ "homepage": "https://caltra.dev/docs/backend-tools",
54
+ "publishConfig": {
55
+ "access": "public",
56
+ "registry": "https://registry.npmjs.org/"
57
+ }
58
+ }