@caltra/backend-sdk 0.0.0-stage → 0.1.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,63 @@
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
+ 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.
62
+
63
+ 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: 1, 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,110 @@
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.ZodLiteral<1>;
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 CallContextSchema: 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 CallSchema: z.ZodObject<{
61
+ invocationId: z.ZodString;
62
+ manifestVersion: z.ZodNumber;
63
+ name: z.ZodString;
64
+ inputs: z.ZodUnknown;
65
+ context: z.ZodUnion<readonly [z.ZodObject<{
66
+ organizationId: z.ZodString;
67
+ workspaceId: z.ZodString;
68
+ environmentId: z.ZodString;
69
+ principal: z.ZodObject<{
70
+ kind: z.ZodLiteral<"platform_user">;
71
+ id: z.ZodString;
72
+ }, z.core.$strict>;
73
+ }, z.core.$strict>, z.ZodObject<{
74
+ organizationId: z.ZodString;
75
+ workspaceId: z.ZodString;
76
+ environmentId: z.ZodString;
77
+ principalId: z.ZodString;
78
+ principal: z.ZodObject<{
79
+ kind: z.ZodEnum<{
80
+ tenant_user: "tenant_user";
81
+ agent_identity: "agent_identity";
82
+ system: "system";
83
+ }>;
84
+ id: z.ZodString;
85
+ }, z.core.$strict>;
86
+ sessionId: z.ZodString;
87
+ turnId: z.ZodString;
88
+ agentIdentityId: z.ZodNullable<z.ZodString>;
89
+ agentId: z.ZodString;
90
+ }, z.core.$strict>]>;
91
+ deadline: z.ZodString;
92
+ }, z.core.$strict>;
93
+ export type ToolManifest = z.infer<typeof ToolManifestSchema>;
94
+ export type ManifestVersion = z.infer<typeof ManifestVersionSchema>;
95
+ export type Registration = z.infer<typeof RegistrationSchema>;
96
+ export type ToolCall = z.infer<typeof CallSchema>;
97
+ export type ToolContext = z.infer<typeof CallContextSchema> & {
98
+ invocationId: string;
99
+ deadline: Date;
100
+ };
101
+ /** Defines the version-one wire contract and a stable digest independent of JSON object key order. */
102
+ export declare class BackendProtocol {
103
+ static readonly path = "/backends/v1/connect";
104
+ static readonly maxBytes: number;
105
+ static readonly heartbeatMs = 10000;
106
+ static readonly leaseMs = 35000;
107
+ static readonly callTimeoutMs = 15000;
108
+ static hash(tools: ToolManifest[]): string;
109
+ private static canonical;
110
+ }
@@ -0,0 +1,47 @@
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.literal(1), 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
+ export const CallContextSchema = z.union([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(), ContextScope.extend({
23
+ principalId: z.string().uuid(), principal: z.object({ kind: z.enum(["tenant_user", "agent_identity", "system"]), id: z.string().uuid() }).strict(),
24
+ sessionId: z.string().uuid(), turnId: z.string().uuid(), agentIdentityId: z.string().uuid().nullable(), agentId: z.string().uuid(),
25
+ }).strict()]);
26
+ export const CallSchema = z.object({
27
+ invocationId: z.string().uuid(), manifestVersion: z.number().int().positive(), name: z.string(),
28
+ inputs: z.unknown(), context: CallContextSchema, deadline: z.string().datetime(),
29
+ }).strict();
30
+ /** Defines the version-one wire contract and a stable digest independent of JSON object key order. */
31
+ export class BackendProtocol {
32
+ static path = "/backends/v1/connect";
33
+ static maxBytes = 1024 * 1024;
34
+ static heartbeatMs = 10000;
35
+ static leaseMs = 35000;
36
+ static callTimeoutMs = 15000;
37
+ static hash(tools) {
38
+ 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");
39
+ }
40
+ static canonical(value) {
41
+ if (Array.isArray(value))
42
+ return value.map(item => this.canonical(item));
43
+ if (value && typeof value === "object")
44
+ return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, item]) => [key, this.canonical(item)]));
45
+ return value;
46
+ }
47
+ }
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(), handledBy: z.string() }).strict(),
19
+ handler: async (context, inputs) => ({ message: inputs.message, workspaceId: context.workspaceId, 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.1.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
+ }