@tidblink/server 0.0.0-stage → 0.4.0-rc.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.
Files changed (116) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +1182 -2
  3. package/dist/audio.d.ts +72 -0
  4. package/dist/audio.d.ts.map +1 -0
  5. package/dist/audio.js +168 -0
  6. package/dist/audio.js.map +1 -0
  7. package/dist/backend-connection.d.ts +89 -0
  8. package/dist/backend-connection.d.ts.map +1 -0
  9. package/dist/backend-connection.js +300 -0
  10. package/dist/backend-connection.js.map +1 -0
  11. package/dist/backend-pool.d.ts +93 -0
  12. package/dist/backend-pool.d.ts.map +1 -0
  13. package/dist/backend-pool.js +419 -0
  14. package/dist/backend-pool.js.map +1 -0
  15. package/dist/client-tool-executor.d.ts +31 -0
  16. package/dist/client-tool-executor.d.ts.map +1 -0
  17. package/dist/client-tool-executor.js +190 -0
  18. package/dist/client-tool-executor.js.map +1 -0
  19. package/dist/client-tools.d.ts +21 -0
  20. package/dist/client-tools.d.ts.map +1 -0
  21. package/dist/client-tools.js +48 -0
  22. package/dist/client-tools.js.map +1 -0
  23. package/dist/connector-callback.d.ts +92 -0
  24. package/dist/connector-callback.d.ts.map +1 -0
  25. package/dist/connector-callback.js +155 -0
  26. package/dist/connector-callback.js.map +1 -0
  27. package/dist/connectors.d.ts +66 -0
  28. package/dist/connectors.d.ts.map +1 -0
  29. package/dist/connectors.js +71 -0
  30. package/dist/connectors.js.map +1 -0
  31. package/dist/control.d.ts +17 -0
  32. package/dist/control.d.ts.map +1 -0
  33. package/dist/control.js +52 -0
  34. package/dist/control.js.map +1 -0
  35. package/dist/errors.d.ts +103 -0
  36. package/dist/errors.d.ts.map +1 -0
  37. package/dist/errors.js +115 -0
  38. package/dist/errors.js.map +1 -0
  39. package/dist/events.d.ts +158 -0
  40. package/dist/events.d.ts.map +1 -0
  41. package/dist/events.js +110 -0
  42. package/dist/events.js.map +1 -0
  43. package/dist/files.d.ts +129 -0
  44. package/dist/files.d.ts.map +1 -0
  45. package/dist/files.js +388 -0
  46. package/dist/files.js.map +1 -0
  47. package/dist/history.d.ts +279 -0
  48. package/dist/history.d.ts.map +1 -0
  49. package/dist/history.js +91 -0
  50. package/dist/history.js.map +1 -0
  51. package/dist/http.d.ts +88 -0
  52. package/dist/http.d.ts.map +1 -0
  53. package/dist/http.js +302 -0
  54. package/dist/http.js.map +1 -0
  55. package/dist/index.d.ts +39 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +18 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/organization.d.ts +87 -0
  60. package/dist/organization.d.ts.map +1 -0
  61. package/dist/organization.js +112 -0
  62. package/dist/organization.js.map +1 -0
  63. package/dist/realtime.d.ts +90 -0
  64. package/dist/realtime.d.ts.map +1 -0
  65. package/dist/realtime.js +111 -0
  66. package/dist/realtime.js.map +1 -0
  67. package/dist/session-connection.d.ts +144 -0
  68. package/dist/session-connection.d.ts.map +1 -0
  69. package/dist/session-connection.js +334 -0
  70. package/dist/session-connection.js.map +1 -0
  71. package/dist/session-send.d.ts +49 -0
  72. package/dist/session-send.d.ts.map +1 -0
  73. package/dist/session-send.js +179 -0
  74. package/dist/session-send.js.map +1 -0
  75. package/dist/session.d.ts +165 -0
  76. package/dist/session.d.ts.map +1 -0
  77. package/dist/session.js +253 -0
  78. package/dist/session.js.map +1 -0
  79. package/dist/skills.d.ts +103 -0
  80. package/dist/skills.d.ts.map +1 -0
  81. package/dist/skills.js +98 -0
  82. package/dist/skills.js.map +1 -0
  83. package/dist/sse.d.ts +10 -0
  84. package/dist/sse.d.ts.map +1 -0
  85. package/dist/sse.js +50 -0
  86. package/dist/sse.js.map +1 -0
  87. package/dist/template-switch.d.ts +51 -0
  88. package/dist/template-switch.d.ts.map +1 -0
  89. package/dist/template-switch.js +81 -0
  90. package/dist/template-switch.js.map +1 -0
  91. package/dist/templates.d.ts +129 -0
  92. package/dist/templates.d.ts.map +1 -0
  93. package/dist/templates.js +92 -0
  94. package/dist/templates.js.map +1 -0
  95. package/dist/turn.d.ts +47 -0
  96. package/dist/turn.d.ts.map +1 -0
  97. package/dist/turn.js +210 -0
  98. package/dist/turn.js.map +1 -0
  99. package/dist/usage.d.ts +278 -0
  100. package/dist/usage.d.ts.map +1 -0
  101. package/dist/usage.js +22 -0
  102. package/dist/usage.js.map +1 -0
  103. package/dist/user.d.ts +55 -0
  104. package/dist/user.d.ts.map +1 -0
  105. package/dist/user.js +52 -0
  106. package/dist/user.js.map +1 -0
  107. package/examples/control-turn.mjs +53 -0
  108. package/examples/create-session.mjs +82 -0
  109. package/examples/http-connector.mjs +127 -0
  110. package/examples/lamp-backend.mjs +175 -0
  111. package/examples/prepare-realtime-connection.mjs +72 -0
  112. package/examples/read-usage.mjs +44 -0
  113. package/examples/send-message.mjs +48 -0
  114. package/examples/submit-audio.mjs +57 -0
  115. package/examples/upload-file.mjs +58 -0
  116. package/package.json +59 -4
@@ -0,0 +1 @@
1
+ {"version":3,"file":"user.js","sourceRoot":"","sources":["../src/user.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AACvC,OAAO,EACL,cAAc,EACd,UAAU,EACV,eAAe,EACf,gBAAgB,EAChB,iBAAiB,GAElB,MAAM,WAAW,CAAC;AAEnB,OAAO,EACL,aAAa,EAEb,OAAO,GAER,MAAM,cAAc,CAAC;AAwCtB,gEAAgE;AAChE,MAAM,OAAO,aAAa;IACf,KAAK,CAAa;IAClB,MAAM,CAAY;IAE3B,IAAI,KAAK;QACP,OAAO,IAAI,CAAC,MAAM,CAAC;IACrB,CAAC;IAED,YAAY,IAAgB;QAC1B,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC;QAClB,IAAI,CAAC,MAAM,GAAG,IAAI,SAAS,CAAC,IAAI,CAAC,CAAC;IACpC,CAAC;IAED;;;OAGG;IACH,aAAa,CAAC,OAA6B;QACzC,OAAO,aAAa,CAAC,IAAI,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC;IAC5C,CAAC;IAED,kBAAkB,CAAC,OAAqB;QACtC,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CACnB,mBAAmB,eAAe,CAAC,OAAO,CAAC,EAAE,EAC7C,OAAO,CACR,CAAC;IACJ,CAAC;IAED,UAAU,CAAC,OAAwB;QACjC,IAAI,OAAO;YACT,gBAAgB,CAAC,OAAO,EAAE,CAAC,QAAQ,EAAE,WAAW,CAAC,EAAE,oBAAoB,CAAC,CAAC;QAC3E,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,eAAe,EAAE,OAAO,CAAC,CAAC;IAClD,CAAC;IAED,kBAAkB,CAAC,OAAwB;QACzC,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,kBAAkB,EAAE,cAAc,CAAC,OAAO,CAAC,CAAC,CAAC;IACrE,CAAC;IAED,KAAK,CAAC,YAAY,CAAC,OAA4B;QAC7C,IAAI,OAAO;YACT,gBAAgB,CACd,OAAO,EACP,CAAC,YAAY,EAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,WAAW,CAAC,EACxD,sBAAsB,CACvB,CAAC;QACJ,MAAM,EAAE,UAAU,EAAE,GAAG,IAAI,EAAE,GAAG,OAAO,IAAI,EAAE,CAAC;QAC9C,MAAM,KAAK,GAAG,IAAI,eAAe,CAAC,eAAe,CAAC,IAAI,CAAC,CAAC,CAAC;QACzD,iBAAiB,CAAC,KAAK,EAAE,UAAU,CAAC,CAAC;QACrC,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,GAAG,CAIlC,YAAY,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,OAAO,CAAC,CAAC;QACzD,OAAO;YACL,GAAG,QAAQ;YACX,QAAQ,EAAE,QAAQ,CAAC,QAAQ,CAAC,GAAG,CAC7B,CAAC,MAAM,EAAE,EAAE,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,KAAK,EAAE,MAAM,CAAC,SAAS,EAAE,MAAM,CAAC,CAC9D;SACF,CAAC;IACJ,CAAC;IAED,OAAO,CAAC,EAAU;QAChB,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IACrC,CAAC;IACD,UAAU,CAAC,EAAU,EAAE,OAAwB;QAC7C,OAAO,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;IAC3C,CAAC;CACF"}
@@ -0,0 +1,53 @@
1
+ import { pathToFileURL } from "node:url";
2
+ import { OrganizationClient } from "@tidblink/server";
3
+
4
+ /** Persist the action and both identities before dispatch; recovery reads that command. */
5
+ export async function controlTurnWorkflow({
6
+ baseUrl,
7
+ organizationApiKey,
8
+ userId,
9
+ sessionId,
10
+ turnId,
11
+ clientEventId,
12
+ action,
13
+ signal,
14
+ }) {
15
+ if (action !== "cancel" && action !== "retry")
16
+ throw new TypeError("action must be cancel or retry");
17
+ const session = new OrganizationClient({
18
+ baseUrl,
19
+ apiKey: organizationApiKey,
20
+ }).user(userId).session(
21
+ sessionId,
22
+ );
23
+ const input = { turnId, clientEventId, ...(signal ? { signal } : {}) };
24
+ if (action === "cancel") return session.cancel(input);
25
+ const accepted = await session.retry(input);
26
+ return session.waitForResult(
27
+ accepted.clientEventId,
28
+ signal ? { signal } : undefined,
29
+ );
30
+ }
31
+
32
+ if (
33
+ process.argv[1] &&
34
+ import.meta.url === pathToFileURL(process.argv[1]).href
35
+ ) {
36
+ try {
37
+ const result = await controlTurnWorkflow({
38
+ baseUrl: process.env.AGENT_SERVICE_URL,
39
+ organizationApiKey: process.env.ORGANIZATION_API_KEY,
40
+ userId: process.env.END_USER_ID,
41
+ sessionId: process.env.SESSION_ID,
42
+ turnId: process.env.TURN_ID,
43
+ clientEventId: process.env.CLIENT_EVENT_ID,
44
+ action: process.env.TURN_ACTION,
45
+ });
46
+ console.log(JSON.stringify(result));
47
+ } catch (error) {
48
+ console.error(
49
+ error instanceof Error ? error.message : "Turn control failed",
50
+ );
51
+ process.exitCode = 1;
52
+ }
53
+ }
@@ -0,0 +1,82 @@
1
+ import { pathToFileURL } from "node:url";
2
+ import { OrganizationClient } from "@tidblink/server";
3
+
4
+ /**
5
+ * Retain the owner, selected Template, optional Session options and the
6
+ * creation key before calling. Pass `acknowledgeSessionRecall: true` when the
7
+ * Template enables cross-Session Recall.
8
+ */
9
+ export async function createSessionWorkflow({
10
+ baseUrl,
11
+ organizationApiKey,
12
+ userId,
13
+ templateId,
14
+ sessionCreationKey,
15
+ model,
16
+ additionalInstructions,
17
+ sandbox,
18
+ acknowledgeSessionRecall,
19
+ }) {
20
+ if (!templateId)
21
+ throw new TypeError(
22
+ "Select and retain a templateId before creating a Session",
23
+ );
24
+ const organization = new OrganizationClient({
25
+ baseUrl,
26
+ apiKey: organizationApiKey,
27
+ });
28
+ const user = organization.user(userId);
29
+ const templates = await user.listAgentTemplates();
30
+ const models = await user.listModels();
31
+ // Omission is part of the creation identity; resolving today's default would change a replay.
32
+ const optional = { model, additionalInstructions, sandbox, acknowledgeSessionRecall };
33
+ const session = await user.createSession({
34
+ templateId,
35
+ idempotencyKey: sessionCreationKey,
36
+ ...Object.fromEntries(
37
+ Object.entries(optional).filter(([, value]) => value !== undefined),
38
+ ),
39
+ });
40
+ return { organization, user, session, templates, models };
41
+ }
42
+
43
+ if (
44
+ process.argv[1] &&
45
+ import.meta.url === pathToFileURL(process.argv[1]).href
46
+ ) {
47
+ const {
48
+ AGENT_SERVICE_URL,
49
+ ORGANIZATION_API_KEY,
50
+ END_USER_ID,
51
+ TEMPLATE_ID,
52
+ SESSION_CREATION_KEY,
53
+ MODEL_ID,
54
+ ACKNOWLEDGE_SESSION_RECALL,
55
+ } = process.env;
56
+ if (
57
+ !AGENT_SERVICE_URL ||
58
+ !ORGANIZATION_API_KEY ||
59
+ !END_USER_ID ||
60
+ !TEMPLATE_ID ||
61
+ !SESSION_CREATION_KEY
62
+ )
63
+ throw new Error(
64
+ "Set AGENT_SERVICE_URL, ORGANIZATION_API_KEY, END_USER_ID, TEMPLATE_ID and SESSION_CREATION_KEY; MODEL_ID and ACKNOWLEDGE_SESSION_RECALL=true are optional",
65
+ );
66
+ const result = await createSessionWorkflow({
67
+ baseUrl: AGENT_SERVICE_URL,
68
+ organizationApiKey: ORGANIZATION_API_KEY,
69
+ userId: END_USER_ID,
70
+ sessionCreationKey: SESSION_CREATION_KEY,
71
+ templateId: TEMPLATE_ID,
72
+ model: MODEL_ID,
73
+ acknowledgeSessionRecall:
74
+ ACKNOWLEDGE_SESSION_RECALL === "true" ? true : undefined,
75
+ });
76
+ console.log(
77
+ JSON.stringify({
78
+ sessionId: result.session.id,
79
+ model: result.session.model,
80
+ }),
81
+ );
82
+ }
@@ -0,0 +1,127 @@
1
+ import { createServer } from "node:http";
2
+ import { pathToFileURL } from "node:url";
3
+ import {
4
+ ConnectorAuthError,
5
+ ConnectorKeySet,
6
+ readConnectorCallContext,
7
+ verifyConnectorCallback,
8
+ } from "@tidblink/server";
9
+
10
+ /**
11
+ * A minimal HTTP Connector: an MCP server over Streamable HTTP with plain
12
+ * JSON responses, without an MCP library. Every request must carry a
13
+ * platform-signed token for this Organization and Connector; every tool call
14
+ * carries the Call Context naming the End User it is for.
15
+ *
16
+ * `tools` maps a tool name to `{ description, inputSchema, run(args, context) }`.
17
+ * `run` returns text; deduplicate its side effects by `context.callId`.
18
+ * Agents see each tool as `mcp_<name>__<tool>`, both parts lowercased with
19
+ * other characters replaced by `_`.
20
+ */
21
+ export function createConnectorHandler({
22
+ baseUrl,
23
+ organizationId,
24
+ connectorId,
25
+ tools,
26
+ name = "example-connector",
27
+ keys = new ConnectorKeySet({ baseUrl }),
28
+ }) {
29
+ const catalog = Object.entries(tools).map(([name, tool]) => ({
30
+ name,
31
+ description: tool.description,
32
+ inputSchema: tool.inputSchema,
33
+ }));
34
+ const answer = (response, status, body) => {
35
+ response.writeHead(status, body ? { "content-type": "application/json" } : {});
36
+ response.end(body ? JSON.stringify(body) : undefined);
37
+ };
38
+ return async (request, response) => {
39
+ try {
40
+ await verifyConnectorCallback(request.headers.authorization, {
41
+ keys,
42
+ organizationId,
43
+ connectorId,
44
+ });
45
+ } catch (error) {
46
+ if (error instanceof ConnectorAuthError) return answer(response, 401);
47
+ return answer(response, 503);
48
+ }
49
+ if (request.method === "DELETE") return answer(response, 200);
50
+ if (request.method !== "POST") return answer(response, 405);
51
+ let message;
52
+ try {
53
+ const chunks = [];
54
+ for await (const chunk of request) chunks.push(chunk);
55
+ message = JSON.parse(Buffer.concat(chunks).toString("utf8"));
56
+ } catch {
57
+ return answer(response, 400, {
58
+ jsonrpc: "2.0",
59
+ id: null,
60
+ error: { code: -32700, message: "Parse error" },
61
+ });
62
+ }
63
+ // Notifications such as notifications/initialized need no answer.
64
+ if (message.id === undefined) return answer(response, 202);
65
+ const reply = (result) => answer(response, 200, { jsonrpc: "2.0", id: message.id, result });
66
+ const fail = (code, text) =>
67
+ answer(response, 200, { jsonrpc: "2.0", id: message.id, error: { code, message: text } });
68
+ switch (message.method) {
69
+ case "initialize":
70
+ return reply({
71
+ protocolVersion: "2025-03-26",
72
+ capabilities: { tools: {} },
73
+ serverInfo: { name, version: "1.0.0" },
74
+ });
75
+ case "ping":
76
+ return reply({});
77
+ case "tools/list":
78
+ return reply({ tools: catalog });
79
+ case "tools/call": {
80
+ const tool = tools[message.params?.name];
81
+ if (!tool) return fail(-32602, "Unknown tool");
82
+ let context;
83
+ try {
84
+ context = readConnectorCallContext(message.params?._meta, { organizationId });
85
+ } catch (error) {
86
+ return fail(-32602, error.message);
87
+ }
88
+ try {
89
+ const text = await tool.run(message.params.arguments ?? {}, context);
90
+ return reply({ content: [{ type: "text", text: String(text) }] });
91
+ } catch (error) {
92
+ return reply({
93
+ content: [{ type: "text", text: error instanceof Error ? error.message : "Tool failed" }],
94
+ isError: true,
95
+ });
96
+ }
97
+ }
98
+ default:
99
+ return fail(-32601, "Method not found");
100
+ }
101
+ };
102
+ }
103
+
104
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
105
+ const { AGENT_SERVICE_URL, ORGANIZATION_ID, CONNECTOR_ID, PORT = "8080" } = process.env;
106
+ if (!AGENT_SERVICE_URL || !ORGANIZATION_ID || !CONNECTOR_ID)
107
+ throw new Error("Set AGENT_SERVICE_URL, ORGANIZATION_ID and CONNECTOR_ID; PORT is optional");
108
+ const handler = createConnectorHandler({
109
+ baseUrl: AGENT_SERVICE_URL,
110
+ organizationId: ORGANIZATION_ID,
111
+ connectorId: CONNECTOR_ID,
112
+ tools: {
113
+ lookup_order: {
114
+ description: "Look up one of the user's orders",
115
+ inputSchema: {
116
+ type: "object",
117
+ properties: { orderId: { type: "string" } },
118
+ required: ["orderId"],
119
+ },
120
+ run: async ({ orderId }, { userId }) =>
121
+ JSON.stringify({ orderId, userId, status: "shipped" }),
122
+ },
123
+ },
124
+ });
125
+ // Serve behind your HTTPS terminator; register its HTTPS URL as endpointUrl.
126
+ createServer(handler).listen(Number(PORT));
127
+ }
@@ -0,0 +1,175 @@
1
+ import { OrganizationClient } from "@tidblink/server";
2
+
3
+ /**
4
+ * The backend of a smart lamp that users talk to by typing in an app or by
5
+ * speaking to the lamp. Both share one Session, so one conversation history.
6
+ *
7
+ * - The lamp's functions are Client Tools: the model calls them, this backend
8
+ * runs them by forwarding to the lamp over your own device channel (MQTT,
9
+ * WebSocket, ...), and the core never talks to the lamp except for audio.
10
+ * - While the lamp is online the backend keeps its Session connected, so the
11
+ * lamp's tools work for voice too. Typing while the lamp is offline still
12
+ * works; its tools then fail at once and the model says so.
13
+ * - For voice the backend hands the lamp a short-lived grant; the lamp opens
14
+ * the Realtime WebSocket itself and audio never passes through here.
15
+ *
16
+ * `device` is your channel to the lamps:
17
+ * isOnline(deviceId) -> boolean
18
+ * request(deviceId, command, args, { signal }) -> Promise<result>, rejects on failure
19
+ * send(deviceId, topic, payload) -> void (fire-and-forget, e.g. the voice grant)
20
+ * `store` keeps who owns which lamp and where each Session's events were read;
21
+ * the default keeps it in memory.
22
+ */
23
+ export function createLampBackend({
24
+ baseUrl,
25
+ organizationApiKey,
26
+ templateId,
27
+ device,
28
+ store = memoryStore(),
29
+ onChat = () => {},
30
+ }) {
31
+ const organization = new OrganizationClient({ baseUrl, apiKey: organizationApiKey });
32
+ /** deviceId -> SessionConnection while the lamp is online. */
33
+ const online = new Map();
34
+
35
+ // ── The lamp's functions (edge capabilities) ──────────────────────────────
36
+ const lampTools = (deviceId) => {
37
+ const ask = async (command, args, signal) => {
38
+ // Fail at once instead of letting the model wait for a lamp that is not there.
39
+ if (!device.isOnline(deviceId)) throw new Error("The lamp is offline");
40
+ return device.request(deviceId, command, args, { signal });
41
+ };
42
+ return {
43
+ set_light: {
44
+ description: "Turn the lamp on or off, or set its brightness and colour temperature",
45
+ parameters: {
46
+ type: "object",
47
+ properties: {
48
+ on: { type: "boolean", description: "true turns the lamp on, false off" },
49
+ brightness: { type: "integer", minimum: 1, maximum: 100, description: "Percent" },
50
+ colorTemp: { type: "integer", minimum: 2700, maximum: 6500, description: "Kelvin" },
51
+ },
52
+ additionalProperties: false,
53
+ },
54
+ run: async (args, { signal }) => {
55
+ const state = await ask("set_light", args, signal);
56
+ return state.on
57
+ ? `The lamp is on at ${state.brightness}% and ${state.colorTemp} K`
58
+ : "The lamp is off";
59
+ },
60
+ },
61
+ get_status: {
62
+ description: "Read the lamp's state and the ambient light",
63
+ parameters: { type: "object", properties: {}, additionalProperties: false },
64
+ run: async (_args, { signal }) => JSON.stringify(await ask("get_status", {}, signal)),
65
+ },
66
+ set_timer: {
67
+ description: "Turn the lamp off after some minutes",
68
+ parameters: {
69
+ type: "object",
70
+ properties: { minutes: { type: "integer", minimum: 1, maximum: 240 } },
71
+ required: ["minutes"],
72
+ additionalProperties: false,
73
+ },
74
+ run: async ({ minutes }, { signal }) => {
75
+ await ask("set_timer", { minutes, action: "off" }, signal);
76
+ return `The lamp turns off in ${minutes} minutes`;
77
+ },
78
+ },
79
+ };
80
+ };
81
+
82
+ const sessionOf = async (deviceId) => {
83
+ const binding = await store.binding(deviceId);
84
+ if (!binding) throw new Error(`Lamp ${deviceId} is not bound to a user`);
85
+ return organization.user(binding.userId).session(binding.sessionId);
86
+ };
87
+
88
+ const connect = async (deviceId) =>
89
+ (await sessionOf(deviceId)).connect({
90
+ tools: lampTools(deviceId),
91
+ cursor: await store.cursor(deviceId),
92
+ onCursor: (cursor) => store.saveCursor(deviceId, cursor),
93
+ // What was said by voice also appears in the app.
94
+ onTranscript: (t) => onChat(deviceId, { role: "user", text: t.text }),
95
+ onResponse: (r) => onChat(deviceId, { role: "assistant", text: r.text }),
96
+ });
97
+
98
+ return {
99
+ organization,
100
+
101
+ /** The user binds a lamp in the app: one End User per app user, one Session per lamp. */
102
+ async bindDevice(appUserId, deviceId) {
103
+ const user = await organization.createEndUser({ externalId: appUserId }); // safe to repeat
104
+ const session = await organization.user(user.userId).createSession({
105
+ templateId,
106
+ idempotencyKey: `lamp.${deviceId}`, // one Session per lamp, also after a retry
107
+ });
108
+ await store.bind(deviceId, { userId: user.userId, sessionId: session.id });
109
+ return session.id;
110
+ },
111
+
112
+ /** The lamp came online: keep its Session connected while it stays online. */
113
+ async onDeviceOnline(deviceId) {
114
+ if (online.has(deviceId)) return;
115
+ const conn = await connect(deviceId);
116
+ online.set(deviceId, conn);
117
+ // Another backend process took the lamp over, or the Session was deleted.
118
+ conn.closed.then(() => {
119
+ if (online.get(deviceId) === conn) online.delete(deviceId);
120
+ });
121
+ },
122
+
123
+ /** The lamp went offline: release its Session. */
124
+ async onDeviceOffline(deviceId) {
125
+ const conn = online.get(deviceId);
126
+ online.delete(deviceId);
127
+ await conn?.close();
128
+ },
129
+
130
+ /**
131
+ * The user typed in the app. Uses the lamp's connection when it is online,
132
+ * otherwise a temporary one. `onDraft` receives the growing answer.
133
+ */
134
+ async onAppMessage(deviceId, text, { clientEventId, onDraft } = {}) {
135
+ const existing = online.get(deviceId);
136
+ const conn = existing ?? (await connect(deviceId));
137
+ try {
138
+ const reply = await conn.send(text, {
139
+ ...(clientEventId ? { clientEventId } : {}),
140
+ ...(onDraft ? { onProgress: onDraft } : {}),
141
+ });
142
+ return reply.text;
143
+ } finally {
144
+ if (!existing) await conn.close();
145
+ }
146
+ },
147
+
148
+ /** The user pressed the talk button: give the lamp a short-lived grant to open voice. */
149
+ async onDeviceWantsToTalk(deviceId) {
150
+ const conn = online.get(deviceId);
151
+ if (!conn) throw new Error(`Lamp ${deviceId} is not online`);
152
+ const grant = await conn.realtimeGrant();
153
+ device.send(deviceId, "voice_grant", grant);
154
+ return grant;
155
+ },
156
+
157
+ /** Release every Session and close the Backend Connections, e.g. on shutdown. */
158
+ async close() {
159
+ online.clear();
160
+ await organization.close();
161
+ },
162
+ };
163
+ }
164
+
165
+ /** In-memory bindings and cursors; use your database in production. */
166
+ export function memoryStore() {
167
+ const bindings = new Map();
168
+ const cursors = new Map();
169
+ return {
170
+ bind: async (deviceId, binding) => void bindings.set(deviceId, binding),
171
+ binding: async (deviceId) => bindings.get(deviceId),
172
+ cursor: async (deviceId) => cursors.get(deviceId),
173
+ saveCursor: async (deviceId, cursor) => void cursors.set(deviceId, cursor),
174
+ };
175
+ }
@@ -0,0 +1,72 @@
1
+ import { pathToFileURL } from "node:url";
2
+ import { OrganizationClient } from "@tidblink/server";
3
+
4
+ /** Prepare an existing owned Session for a terminal to connect; this does not open a WebSocket. */
5
+ export async function prepareRealtimeConnection({
6
+ baseUrl,
7
+ organizationApiKey,
8
+ userId,
9
+ sessionId,
10
+ realtimeModelId,
11
+ audio,
12
+ metadata,
13
+ }) {
14
+ const client = new OrganizationClient({
15
+ baseUrl,
16
+ apiKey: organizationApiKey,
17
+ }).user(userId);
18
+ const catalog = await client.listRealtimeModels();
19
+ const session = client.session(sessionId);
20
+ let selection = await session.getRealtimeModelSelection();
21
+ if (
22
+ realtimeModelId !== undefined &&
23
+ realtimeModelId !== selection.model
24
+ ) {
25
+ selection = await session.setRealtimeModel(realtimeModelId, {
26
+ ifMatch: selection.etag,
27
+ });
28
+ }
29
+ const prepared = await session.openRealtimeConnection({
30
+ ...(audio === undefined ? {} : { audio }),
31
+ ...(metadata === undefined ? {} : { metadata }),
32
+ });
33
+ return { catalog, selection, ...prepared };
34
+ }
35
+
36
+ if (
37
+ process.argv[1] &&
38
+ import.meta.url === pathToFileURL(process.argv[1]).href
39
+ ) {
40
+ const {
41
+ AGENT_SERVICE_URL,
42
+ ORGANIZATION_API_KEY,
43
+ END_USER_ID,
44
+ SESSION_ID,
45
+ REALTIME_MODEL_ID,
46
+ } = process.env;
47
+ if (
48
+ !AGENT_SERVICE_URL ||
49
+ !ORGANIZATION_API_KEY ||
50
+ !END_USER_ID ||
51
+ !SESSION_ID
52
+ ) {
53
+ throw new Error(
54
+ "Set AGENT_SERVICE_URL, ORGANIZATION_API_KEY, END_USER_ID and SESSION_ID; REALTIME_MODEL_ID is optional",
55
+ );
56
+ }
57
+ const result = await prepareRealtimeConnection({
58
+ baseUrl: AGENT_SERVICE_URL,
59
+ organizationApiKey: ORGANIZATION_API_KEY,
60
+ userId: END_USER_ID,
61
+ sessionId: SESSION_ID,
62
+ ...(REALTIME_MODEL_ID ? { realtimeModelId: REALTIME_MODEL_ID } : {}),
63
+ });
64
+ // The one-time connect token is returned to imported callers but never printed by this CLI.
65
+ console.log(
66
+ JSON.stringify({
67
+ realtimeConnection: result.realtimeConnection,
68
+ websocketUrl: result.websocketUrl,
69
+ connectTokenExpiresAt: result.connectTokenExpiresAt,
70
+ }),
71
+ );
72
+ }
@@ -0,0 +1,44 @@
1
+ import { pathToFileURL } from "node:url";
2
+ import { OrganizationClient } from "@tidblink/server";
3
+
4
+ /** Organization details return the first page; retain nextCursor and the original filters. */
5
+ export async function readUserUsage({
6
+ baseUrl,
7
+ organizationApiKey,
8
+ userId,
9
+ options,
10
+ }) {
11
+ const organization = new OrganizationClient({
12
+ baseUrl,
13
+ apiKey: organizationApiKey,
14
+ });
15
+ const { interval, ...range } = options;
16
+ return {
17
+ summary: await organization.getUserUsage(userId, options),
18
+ details: await organization.getUserUsageDetails(userId, range),
19
+ };
20
+ }
21
+
22
+ if (
23
+ process.argv[1] &&
24
+ import.meta.url === pathToFileURL(process.argv[1]).href
25
+ ) {
26
+ try {
27
+ const options = {
28
+ from: process.env.USAGE_FROM,
29
+ to: process.env.USAGE_TO,
30
+ tagKey: process.env.TAG_KEY,
31
+ tagValue: process.env.TAG_VALUE,
32
+ };
33
+ const result = await readUserUsage({
34
+ baseUrl: process.env.AGENT_SERVICE_URL,
35
+ organizationApiKey: process.env.ORGANIZATION_API_KEY,
36
+ userId: process.env.END_USER_ID,
37
+ options,
38
+ });
39
+ console.log(JSON.stringify(result));
40
+ } catch (error) {
41
+ console.error(error instanceof Error ? error.message : "Usage read failed");
42
+ process.exitCode = 1;
43
+ }
44
+ }
@@ -0,0 +1,48 @@
1
+ import { pathToFileURL } from "node:url";
2
+ import { OrganizationClient } from "@tidblink/server";
3
+
4
+ /** Persist sessionId, clientEventId and text before calling; reuse them for recovery. */
5
+ export async function sendMessageWorkflow({
6
+ baseUrl,
7
+ organizationApiKey,
8
+ userId,
9
+ sessionId,
10
+ clientEventId,
11
+ text,
12
+ timeoutMs,
13
+ signal,
14
+ }) {
15
+ const session = new OrganizationClient({
16
+ baseUrl,
17
+ apiKey: organizationApiKey,
18
+ }).user(userId).session(
19
+ sessionId,
20
+ );
21
+ return session.turn(
22
+ { clientEventId, text, ...(signal ? { signal } : {}) },
23
+ {
24
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
25
+ ...(signal ? { signal } : {}),
26
+ },
27
+ );
28
+ }
29
+
30
+ if (
31
+ process.argv[1] &&
32
+ import.meta.url === pathToFileURL(process.argv[1]).href
33
+ ) {
34
+ try {
35
+ const result = await sendMessageWorkflow({
36
+ baseUrl: process.env.AGENT_SERVICE_URL,
37
+ organizationApiKey: process.env.ORGANIZATION_API_KEY,
38
+ userId: process.env.END_USER_ID,
39
+ sessionId: process.env.SESSION_ID,
40
+ clientEventId: process.env.CLIENT_EVENT_ID,
41
+ text: process.env.MESSAGE_TEXT,
42
+ });
43
+ console.log(result.text);
44
+ } catch (error) {
45
+ console.error(error instanceof Error ? error.message : "Message failed");
46
+ process.exitCode = 1;
47
+ }
48
+ }
@@ -0,0 +1,57 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { basename } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { OrganizationClient } from "@tidblink/server";
5
+
6
+ /** This recording request owns its response; aborting it may interrupt the Turn. */
7
+ export async function submitAudioWorkflow({
8
+ baseUrl,
9
+ organizationApiKey,
10
+ userId,
11
+ sessionId,
12
+ bytes,
13
+ filename,
14
+ contentType,
15
+ billingTag,
16
+ signal,
17
+ }) {
18
+ return new OrganizationClient({
19
+ baseUrl,
20
+ apiKey: organizationApiKey,
21
+ }).user(userId)
22
+ .session(sessionId)
23
+ .audio({
24
+ bytes,
25
+ filename,
26
+ ...(contentType === undefined ? {} : { contentType }),
27
+ ...(billingTag === undefined ? {} : { billingTag }),
28
+ ...(signal ? { signal } : {}),
29
+ });
30
+ }
31
+
32
+ if (
33
+ process.argv[1] &&
34
+ import.meta.url === pathToFileURL(process.argv[1]).href
35
+ ) {
36
+ try {
37
+ if (!process.env.AUDIO_FILE_PATH)
38
+ throw new Error("Set AUDIO_FILE_PATH to an existing recording");
39
+ const result = await submitAudioWorkflow({
40
+ baseUrl: process.env.AGENT_SERVICE_URL,
41
+ organizationApiKey: process.env.ORGANIZATION_API_KEY,
42
+ userId: process.env.END_USER_ID,
43
+ sessionId: process.env.SESSION_ID,
44
+ filename: basename(process.env.AUDIO_FILE_PATH),
45
+ bytes: await readFile(process.env.AUDIO_FILE_PATH),
46
+ contentType: process.env.AUDIO_CONTENT_TYPE,
47
+ });
48
+ console.log(result.text);
49
+ } catch (error) {
50
+ console.error(
51
+ error instanceof Error ? error.message : "Audio request failed",
52
+ );
53
+ if (typeof error?.turnId === "string")
54
+ console.error(`Turn: ${error.turnId}`);
55
+ process.exitCode = 1;
56
+ }
57
+ }