@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
package/README.md CHANGED
@@ -1,3 +1,1183 @@
1
- # Temporary Holding Version
1
+ # `@tidblink/server`
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
+ Official TiDB Link SDK for backends, for Node.js 22.12 or newer. ESM only, with
4
+ TypeScript declarations and no runtime dependencies. It uses API Keys that must
5
+ stay on your backend; do not ship it to browsers or devices.
6
+
7
+ ## Supported scope
8
+
9
+ This package supports Organization information, End User provisioning,
10
+ Template, Skill and Connector management, Connector callback verification,
11
+ Template/model discovery, Session management and Template switches,
12
+ text submission, steering, cancellation, checkpoint retry, original-result waiting,
13
+ continuous progress, Client Tools, Session connections over Backend Connections,
14
+ conversation history, file attachments,
15
+ Realtime Model/Connection preparation, prerecorded audio and User usage reads for [service revision
16
+ 4305ef67](https://github.com/mem9-ai/agent-stack/commit/4305ef67daebf23c416badf806217a7c11dcef37).
17
+
18
+ Text commands return durable receipts independently of execution. Waiting reads the
19
+ original command's outcome. The service has no Agent: a Session is created
20
+ directly from an explicit Template, and there is no default Template. Browser, MSP, Console and device APIs are
21
+ outside this package. Every resource workflow uses an Organization API Key with
22
+ an explicitly selected End User; User API Keys are not supported.
23
+
24
+ ## Quick start
25
+
26
+ Install an exact published version. Prereleases are on the `next` dist-tag:
27
+
28
+ ```sh
29
+ npm install --save-exact @tidblink/server@next
30
+ ```
31
+
32
+ An application backend uses its Organization API Key (`ti_org_…`) and explicitly
33
+ selects the ordinary End User with `organization.user(userId)`. The Key remains
34
+ the caller; the User owns the resources.
35
+
36
+ ```js
37
+ import { randomUUID } from "node:crypto";
38
+ import { OrganizationClient } from "@tidblink/server";
39
+ import { createSessionWorkflow } from "@tidblink/server/examples/create-session.mjs";
40
+
41
+ const baseUrl = process.env.AGENT_SERVICE_URL;
42
+ const organizationApiKey = process.env.ORGANIZATION_API_KEY;
43
+ const organization = new OrganizationClient({ baseUrl, apiKey: organizationApiKey });
44
+ const endUser = await organization.createEndUser({ externalId: "your-application-user-id" });
45
+ // Safe to repeat: the same externalId returns the same End User.
46
+ const user = organization.user(endUser.userId);
47
+ const page = await user.listAgentTemplates();
48
+ const template = page.templates[0];
49
+ if (!template) throw new Error("Select an available Template; follow nextCursor if hasMore");
50
+ const creation = {
51
+ baseUrl, organizationApiKey, userId: endUser.userId,
52
+ templateId: template.agentTemplateId,
53
+ sessionCreationKey: randomUUID(),
54
+ };
55
+ // Persist the selected Template, optional model and the creation identity.
56
+ // Add acknowledgeSessionRecall: true when the Template enables cross-Session Recall.
57
+ const { session } = await createSessionWorkflow(creation);
58
+ const message = { clientEventId: randomUUID(), text: "Help me plan a trip" };
59
+ // Retain the Session, command identity and original input before dispatch.
60
+ const receipt = await session.submitMessage(message);
61
+ const result = await session.waitForResult(receipt.inputId);
62
+ console.log(result.text);
63
+ const recovered = await session.waitForResult(message.clientEventId);
64
+ ```
65
+
66
+ `externalId` is unique within an Organization, and creation with it is
67
+ idempotent: a repeated `createEndUser` returns the existing End User unchanged and
68
+ does not apply the other fields, and concurrent calls create one End User. The SDK
69
+ retries such calls automatically. An `externalId` that equals an existing one only
70
+ when case and accents are ignored is refused with `resource_conflict`. Without an
71
+ `externalId`, every call creates a new End User. A Template that enables
72
+ cross-Session Recall refuses Session creation with
73
+ `agent_template_recall_acknowledgment_required` (HTTP 409) unless the input
74
+ includes `acknowledgeSessionRecall: true`. Session creation replays require the same creation key and
75
+ inputs. Reading the original message receipt or waiting again does not execute
76
+ another Turn. An HTTP request ID is diagnostic, not a creation identity.
77
+
78
+ The installed example can run with `AGENT_SERVICE_URL`,
79
+ `ORGANIZATION_API_KEY`, `END_USER_ID`, `TEMPLATE_ID` and
80
+ `SESSION_CREATION_KEY` (`MODEL_ID` and `ACKNOWLEDGE_SESSION_RECALL=true` are optional):
81
+
82
+ ```sh
83
+ node node_modules/@tidblink/server/examples/create-session.mjs
84
+ ```
85
+
86
+ `organization.user(userId)` returns an `EndUserClient` with that User's
87
+ Session, file and catalog methods. Selecting a User sends no request.
88
+ Usage reads stay on `organization.getUserUsage(userId, options)` and
89
+ `getUserUsageDetails`.
90
+ Private MCP registration, general Drive entry management, Template switching,
91
+ Schedulers and native Hardware APIs are not wrapped by this SDK. Use the
92
+ pinned public HTTP contract for those existing service capabilities. Building ToD,
93
+ a device gateway or management integration remains outside this package.
94
+ Artifact APIs remain gated. The SDK prepares Realtime HTTP grants; applications
95
+ own WebSocket/media transport. This change adds neither a generated full API
96
+ client nor a media client.
97
+
98
+ ### Credential rotation
99
+
100
+ After Organization Key rotation or revocation, create a new `OrganizationClient`
101
+ with fresh valid authority and select the same User. Existing handles retain their
102
+ old credential. New old-authority requests, subscriptions,
103
+ Realtime tokens and Organization-issued file tickets lose authority. Accepted
104
+ Turns continue, and admitted file transfers may finish. Reconnect/obtain new
105
+ grants with fresh authority and reconcile the original command before submitting
106
+ new work. Another valid Key remains usable. Disabled Users cannot start new work;
107
+ Organization authority can still read retained history/files and perform supported
108
+ cleanup. Application policies and K3 spending policy are separate concerns.
109
+
110
+ ### Custom transport
111
+
112
+ Pass `fetch` to route every request through your own transport, for example a
113
+ proxy, a custom HTTP agent, instrumentation or a test double. It carries all
114
+ requests the client makes: JSON calls, event streams, and file transfers to
115
+ presigned storage URLs (which never receive the API Key).
116
+
117
+ ```js
118
+ const organization = new OrganizationClient({
119
+ baseUrl,
120
+ apiKey: organizationApiKey,
121
+ fetch: (url, init) => fetch(url, { ...init, dispatcher: proxyAgent }),
122
+ });
123
+ ```
124
+
125
+ ## Managing Templates and Skills
126
+
127
+ A Template defines what a Session runs: its identity, instructions, Sandbox,
128
+ voice language, Skills and Connectors. `organization.templates` manages the Organization's Templates, and
129
+ `organization.skills` uploads and publishes the Skills they reference. Both act
130
+ on the Organization; they need no End User.
131
+
132
+ ```js
133
+ const catalog = await organization.templates.catalog();
134
+ const created = await organization.templates.create({
135
+ displayName: "Support assistant",
136
+ content: {
137
+ identity: { agentName: "Ava" },
138
+ instructions: "Answer support questions briefly.",
139
+ sandbox: { profileId: catalog.sandboxes[0].id },
140
+ realtime: { language: "en-US" },
141
+ },
142
+ idempotencyKey: templateCreationKey,
143
+ });
144
+ // Replace and archive take the ETag you read; a stale one is a ConflictError.
145
+ await organization.templates.replace(created.template.id, { displayName, content }, {
146
+ ifMatch: created.template.etag,
147
+ idempotencyKey: templateChangeKey,
148
+ });
149
+ ```
150
+
151
+ `templates.catalog()` lists the models, Sandbox profiles and published Skills a
152
+ Template may reference. `templates.list()` filters by `lifecycleStatus` and `q`
153
+ and pages with `cursor`. Replacing a Template does not change Sessions already
154
+ created from it; they keep the content snapshot identified by
155
+ `session.data.template.contentDigest`. An archived Template can no longer start
156
+ Sessions. Creation, replacement and archive are retried automatically with their
157
+ idempotency key; after an `OutcomeUnknownError`, repeat the call with the same key
158
+ and input.
159
+
160
+ A Skill is a ZIP package with a `SKILL.md` at its root. Uploading validates it
161
+ asynchronously; publishing makes it the Skill's next version:
162
+
163
+ ```js
164
+ const upload = await organization.skills.upload({ bytes: zipBytes, idempotencyKey });
165
+ const validated = await organization.skills.waitForUpload(upload.uploadId);
166
+ if (validated.state !== "awaiting_confirmation")
167
+ throw new Error(validated.validation.failureCode ?? validated.state);
168
+ await organization.skills.publish(upload.uploadId, { idempotencyKey: publishKey });
169
+ const published = await organization.skills.waitForUpload(upload.uploadId, { until: ["done", "failed"] });
170
+ // Reference it from a Template: content.skills = [{ skillId, version }]
171
+ const reference = { skillId: published.skillId, version: published.version };
172
+ ```
173
+
174
+ Each upload creates a new Skill, and an upload is visible only to the Organization
175
+ API Key that created it. Publish before `expiresAt`, or cancel an unpublished
176
+ upload with `cancelUpload`. `skills.list()` and `skills.get(skillId)` read
177
+ published Skills and their versions.
178
+
179
+ ## Connectors
180
+
181
+ A Connector is an Organization-owned MCP tool server that Sessions call. The
182
+ platform calls it for the End User who owns the Session and names that User in
183
+ every call, so your server can act on the right account without trusting model
184
+ arguments.
185
+
186
+ ```js
187
+ let connector = await organization.connectors.register({
188
+ displayName: "Orders",
189
+ endpointUrl: "https://orders.example.com/mcp",
190
+ });
191
+ // Activation connects to the endpoint and discovers its tools.
192
+ connector = await organization.connectors.activate(connector.connectorId, {
193
+ expectedConnectorVersion: connector.connectorVersion,
194
+ });
195
+ ```
196
+
197
+ Edit (`update`) only an inactive Connector, and pass the `connectorVersion` you
198
+ read to every change; a stale one is a `ConflictError`. `deactivate` stops new
199
+ calls. Registration is not retried automatically: after an `OutcomeUnknownError`,
200
+ list the Connectors and match the display name, which is unique. Templates refer
201
+ to Connectors through Console tool settings; this SDK does not set them.
202
+
203
+ Your Connector verifies every request the platform sends (initialization, tool
204
+ discovery and tool calls) with `verifyConnectorCallback`, and reads who a tool
205
+ call is for with `readConnectorCallContext`:
206
+
207
+ ```js
208
+ import { ConnectorKeySet, verifyConnectorCallback, readConnectorCallContext } from "@tidblink/server";
209
+
210
+ // Fetch keys only from your trusted deployment URL; they are cached.
211
+ const keys = new ConnectorKeySet({ baseUrl });
212
+ await verifyConnectorCallback(request.headers.authorization, {
213
+ keys, organizationId, connectorId,
214
+ }); // Throws ConnectorAuthError: answer 401.
215
+ // For tools/call:
216
+ const { callId, userId, sessionId, deadline } = readConnectorCallContext(
217
+ message.params._meta, { organizationId },
218
+ );
219
+ ```
220
+
221
+ Tokens are Ed25519-signed, valid for at most 60 seconds, scoped to your
222
+ Organization and Connector, and may be reused within that scope. Deduplicate side
223
+ effects by `callId`; the platform never retries a call (`attempt` is always 1).
224
+ Abandon work after `deadline`. The packaged `http-connector.mjs` example is a
225
+ complete Connector over Streamable HTTP without an MCP library. The model sees each
226
+ tool as `mcp_<server name>__<tool>`. This SDK does not include a client for the
227
+ `tunnel` transport.
228
+
229
+ ## Templates and Sessions
230
+
231
+ Use `organization.user(userId)` to discover available Templates and deployment models. Model
232
+ IDs are strings from the deployment catalog, including newly published models.
233
+ `effectiveDefaultModel` is the create-time default; `defaultModel` is the
234
+ service's canonical catalog default and may differ.
235
+
236
+ ```ts
237
+ import { OrganizationClient } from "@tidblink/server";
238
+
239
+ const organization = new OrganizationClient({
240
+ baseUrl: process.env.AGENT_SERVICE_URL!,
241
+ apiKey: process.env.ORGANIZATION_API_KEY!,
242
+ });
243
+ const customer = organization.user(process.env.END_USER_ID!);
244
+ const page = await customer.listAgentTemplates({ limit: 20 });
245
+ const template = page.templates[0];
246
+ if (!template) throw new Error("Select an available Template");
247
+ const models = await customer.listModels();
248
+ const session = await customer.createSession({
249
+ templateId: template.agentTemplateId,
250
+ idempotencyKey: "customer-conversation-1",
251
+ model: models.effectiveDefaultModel,
252
+ billingTag: { key: "account", value: "customer-1" },
253
+ additionalInstructions: "Prefer concise answers.",
254
+ sandbox: { workingDirectory: "projects/trip" },
255
+ });
256
+ await session.rename("Planning");
257
+ await session.suggestName({ userText: "Plan a trip" }); // Generates and saves a name.
258
+ await session.setBillingTag(null);
259
+ ```
260
+
261
+ `createSession` requires `templateId`, an active Template of the Organization;
262
+ there is no default Template. The other options belong to this Session only:
263
+
264
+ - `additionalInstructions` are added to the Template instructions. The service
265
+ normalizes line endings to LF and trims whitespace, allows up to 4096 UTF-8
266
+ bytes after normalization, and rejects unsupported control characters. The
267
+ Template and additional instructions together are limited to 16 KiB
268
+ (`agent_instructions_too_large`).
269
+ - `sandbox.profileId` selects a Sandbox profile; omit it or pass `null` to use
270
+ the Template's profile. `sandbox.workingDirectory` is a Drive-relative
271
+ directory and defaults to `.`; creating the Session does not create it.
272
+ - `acknowledgeSessionRecall: true` is required when the Template enables
273
+ cross-Session Recall, which can read other Sessions of the same End User;
274
+ otherwise creation fails with `agent_template_recall_acknowledgment_required`.
275
+ - `model`, `realtimeModel`, `billingTag` and Client Tool `tools` work as
276
+ described in their own sections.
277
+
278
+ Persist the creation key before calling. The same key and the same input recover
279
+ the original Session; reusing the key with different input raises
280
+ `ConflictError` with `session_creation_conflict`. Every option participates in
281
+ that identity. Do not generate a new key just because a response was lost.
282
+
283
+ `listAgentTemplates({ limit?, cursor? })` returns
284
+ `{ templates, hasMore, nextCursor }`; `listSessions({ limit?, cursor?, templateId? })`
285
+ returns `{ sessions, hasMore, nextCursor }`, with `Session` handles in `sessions`.
286
+ `templateId` lists only Sessions currently on that Template; keep the same
287
+ `templateId` when following a cursor.
288
+ Continue with the returned opaque cursor until `hasMore` is false, including
289
+ Template pages with no available entries. A changed Template catalog can refuse
290
+ continuation with `agent_template_list_changed`; restart discovery.
291
+ Session cursors belong to their owning User and list. Use `getSession(id)` to
292
+ read a current owned Session; `session(id)` creates a handle without a request.
293
+
294
+ Each Session freezes its Template recipe, digest and creation options.
295
+ Later Template saves do not rewrite it; `session.data.template` names the
296
+ Template and digest in use. Only an explicit
297
+ [Template switch](#switching-a-sessions-template) moves it to a Template's
298
+ current content. Session model selection is separate:
299
+
300
+ ```ts
301
+ const current = await customer.getSession(session.id);
302
+ await current.setModel(models.effectiveDefaultModel);
303
+ // Another handle's intervening model change makes this handle stale.
304
+ // Refresh and make an explicit choice after a ConflictError (HTTP 412).
305
+ ```
306
+
307
+ `setModel` uses the model ETag retained by the handle, reading it first if needed.
308
+ It never silently retries a stale update. Model changes apply to the next Turn;
309
+ active execution, steering and retry retain the model admitted for that Turn.
310
+ Realtime Model selection has its own independent revision token. Availability,
311
+ Organization policy, read-only Sessions and current ownership remain service
312
+ decisions. `session.delete()` updates and returns the Session handle; `.data` contains the deleted tombstone; later ordinary reads
313
+ and lists hide the Session. Active execution or a read-only Session can refuse
314
+ deletion. Name and Billing Tag changes do not change the frozen recipe.
315
+
316
+ The packaged example completes discovery and creation without message execution:
317
+
318
+ ```js
319
+ import { createSessionWorkflow } from "@tidblink/server/examples/create-session.mjs";
320
+
321
+ const { session } = await createSessionWorkflow({
322
+ baseUrl: process.env.AGENT_SERVICE_URL,
323
+ organizationApiKey: process.env.ORGANIZATION_API_KEY,
324
+ userId: process.env.END_USER_ID,
325
+ sessionCreationKey: process.env.SESSION_CREATION_KEY,
326
+ templateId: process.env.TEMPLATE_ID,
327
+ model: process.env.MODEL_ID,
328
+ });
329
+ ```
330
+
331
+ For direct execution, set `AGENT_SERVICE_URL`, `ORGANIZATION_API_KEY`, `END_USER_ID`,
332
+ `TEMPLATE_ID` and `SESSION_CREATION_KEY`; `MODEL_ID` and
333
+ `ACKNOWLEDGE_SESSION_RECALL=true` are optional. The workflow also passes
334
+ `additionalInstructions`, `sandbox` and `acknowledgeSessionRecall` through.
335
+ Select and persist the Template ID, optional model and the key before calling.
336
+ The example also returns the current Template page and model catalog. If no model
337
+ was requested, it preserves omission so the service can recover the original
338
+ creation even after the deployment default changes.
339
+
340
+ ### Switching a Session's Template
341
+
342
+ `session.switchTemplate()` moves a Session to another active Template's current
343
+ content. Switching to the Template the Session is already on also applies that
344
+ Template's latest saved content. Persist a key per switch, as for creation:
345
+
346
+ ```ts
347
+ const receipt = await session.switchTemplate({
348
+ templateId: nextTemplateId,
349
+ idempotencyKey: switchKey,
350
+ // acknowledgeSessionRecall: true, when the target enables cross-Session Recall
351
+ });
352
+ if (receipt.status === "pending") {
353
+ const handoff = await session.getTurn(receipt.handoffTurnId);
354
+ if (handoff.status !== "running") await session.refresh();
355
+ } else if (receipt.status === "failed") {
356
+ console.warn(receipt.error.code);
357
+ }
358
+ ```
359
+
360
+ | `status` | Meaning |
361
+ | --- | --- |
362
+ | `unchanged` | The target's content equals the current configuration; nothing changed and no `handoffTurnId` |
363
+ | `succeeded` | The Session is on the target at the receipt's `configurationEpoch`. A first response says this only when the Session had no history, so no Handoff ran |
364
+ | `pending` | The Session has history, so the Handoff Turn `handoffTurnId` runs under the target Template |
365
+
366
+ A `pending` switch settles when its Handoff Turn does: the Session moves to the
367
+ target, or the switch fails and the Session stays on its previous Template.
368
+ Follow it like any Turn, with `session.getTurn(receipt.handoffTurnId)` or the
369
+ event stream, which reports `agent.session.template_switch.succeeded` or
370
+ `.failed`. `session.refresh()` then shows the new `template` and
371
+ `configurationEpoch`, and `switching` is `false` once it has settled. The receipt
372
+ holds no Session, so `switchTemplate()` never updates `session.data`; call
373
+ `refresh()`. While a switch is pending, new messages and another switch are
374
+ refused with `session_switch_in_progress`.
375
+
376
+ The service refuses a switch it does not record:
377
+
378
+ | Error | Cause |
379
+ | --- | --- |
380
+ | `ConflictError` `session_switch_busy` | A Turn is running or a Realtime Connection is live |
381
+ | `ConflictError` `session_switch_in_progress` | Another switch has not settled |
382
+ | `ConflictError` `agent_template_recall_acknowledgment_required` | The target enables cross-Session Recall; pass `acknowledgeSessionRecall: true` |
383
+ | `ConflictError` `session_read_only` | The Session cannot change Template, such as a Template Test Session |
384
+ | `ConflictError` `resource_conflict` | The target's Realtime language is no longer offered, or state changed during admission |
385
+ | `ConflictError` `idempotency_key_reused` | The key was used with a different Session, Template or acknowledgment |
386
+ | `TiDBLinkApiError` 404 `agent_template_unavailable` | The target is archived, missing or its dependencies are unusable |
387
+ | `TiDBLinkApiError` 404 `session_not_found` | The Session or its owner is unavailable |
388
+ | `TiDBLinkApiError` 503 `turn_admission_closed`, `skill_package_store_unavailable` | Temporarily unavailable; retry later |
389
+
390
+ After a lost response (`OutcomeUnknownError`), repeat the call with the same key
391
+ and input to recover the receipt; the service returns the command's latest
392
+ receipt and records no second switch. Do not poll this way. Commands belong to
393
+ the Organization API Key that sent them, so recover with the same key; after a
394
+ rotation, follow the Session instead. A replay after the Session is deleted or
395
+ its owner loses access is refused with `session_not_found`.
396
+
397
+ The Handoff Turn summarizes the Session so far for the target Template. History
398
+ reads return it as a Turn with `kind: "template_handoff"`, no `userMessage` and
399
+ an empty `userMessages`; when it succeeds, its assistant Message is the summary.
400
+ The service's
401
+ [ADR 0107](https://github.com/mem9-ai/agent-stack/blob/4305ef67daebf23c416badf806217a7c11dcef37/docs/adr/0107-switch-session-template-baselines-through-configuration-epochs.md)
402
+ describes what the Session keeps across a switch (models, Billing Tag, Client
403
+ Tools and creation options) and that cancelling the Handoff Turn fails the
404
+ switch.
405
+
406
+ ## Text messages and original results
407
+
408
+ Persist the Session ID, command identity and complete input before dispatch.
409
+ `clientEventId` is required, Session-local, and contains 1–128 safe characters
410
+ (letters, digits, `_`, `.`, `:`, `-`, starting with a letter or digit). It is
411
+ separate from the diagnostic HTTP `requestId`.
412
+
413
+ ```ts
414
+ const session = customer.session(savedSessionId);
415
+ const input = { clientEventId: savedCommandId, text: "Plan the trip" };
416
+ const receipt = await session.submitMessage(input);
417
+ // Accepted does not mean incorporated or completed.
418
+ const result = await session.waitForResult(receipt.inputId, {
419
+ timeoutMs: 60_000,
420
+ pollIntervalMs: 250,
421
+ });
422
+ console.log(result.text);
423
+ // Or submit once and wait: await session.turn(input, { timeoutMs: 60_000 });
424
+ ```
425
+
426
+ `submitMessage` returns the service's `SessionInteraction`. `mode` distinguishes
427
+ started or steered work from refused inputs; `appliedAt` records actual
428
+ incorporation. `result.status` and `result.completedAt` belong to the original
429
+ execution generation, even after an explicit retry. A running Turn can accept
430
+ another message; each message needs its own identity and incorporation status.
431
+ The same identity and input recover its original receipt. A conflicting reuse
432
+ raises `ConflictError` with `interaction_input_conflict`.
433
+
434
+ Text accepts a string or a nonempty ordered array of up to nine strings for
435
+ clarification answers. `clarificationSourceTurnId` links a saved Clarification
436
+ Item. Optional `billingTag` (including null) and ready `userFileIds` are passed
437
+ through; use [file attachments](#file-attachments) to upload and verify them.
438
+ Successful `TurnResult`
439
+ contains the original `inputId`, `sessionId`, `turnId`, saved `messageId`, `text`
440
+ and `interaction`; a clarification additionally contains `clarification`.
441
+ Drafts, Session idleness, EOF and acceptance never establish success.
442
+
443
+ `waitForResult` reads receipts and the matching saved successful Turn. Its default
444
+ local deadline is 60 seconds; `pollIntervalMs` defaults to 250 milliseconds.
445
+ `TurnWaitTimeoutError` and an aborted wait (`OutcomeUnknownError`) retain
446
+ `sessionId`, `inputId`, any known `turnId`, and the HTTP request ID when available.
447
+ Neither cancels, resubmits or changes the command. `TurnFailedError` and
448
+ `TurnInterruptedError` retain the original command and Turn identity; failure
449
+ uses `turn_failed`, since a later Turn read could describe a different generation.
450
+ A refused or successful-but-unincorporated message raises `InputNotAppliedError`.
451
+ Read its receipt to inspect the original fact.
452
+
453
+ A disconnect, malformed success response, HTTP 408 or server failure during
454
+ submission raises `OutcomeUnknownError` with the original recoverable identity.
455
+ The SDK never automatically repeats text submission. Reconcile explicitly:
456
+
457
+ ```ts
458
+ const original = await session.reconcileInteraction(savedCommandId);
459
+ // null means no receipt was visible at this read; an in-flight request may still arrive.
460
+ if (original) {
461
+ const result = await session.waitForResult(savedCommandId);
462
+ }
463
+ // getInteraction(savedCommandId) returns the receipt, or interaction_not_found (404).
464
+ ```
465
+
466
+ Keep the original input identity when deliberately repeating a submission; the
467
+ service deduplicates it. Generating another identity can execute the effects
468
+ again. `AbortSignal` before dispatch stops submission; after dispatch it cannot
469
+ establish whether the command was accepted.
470
+
471
+ The packaged example uses an existing owned Session and a caller-retained input:
472
+
473
+ ```js
474
+ import { sendMessageWorkflow } from "@tidblink/server/examples/send-message.mjs";
475
+ const result = await sendMessageWorkflow({
476
+ baseUrl: process.env.AGENT_SERVICE_URL,
477
+ organizationApiKey: process.env.ORGANIZATION_API_KEY,
478
+ userId: process.env.END_USER_ID,
479
+ sessionId: process.env.SESSION_ID,
480
+ clientEventId: process.env.CLIENT_EVENT_ID,
481
+ text: process.env.MESSAGE_TEXT,
482
+ });
483
+ ```
484
+
485
+ Set those five environment variables to run the installed example directly:
486
+ `node node_modules/@tidblink/server/examples/send-message.mjs`. It prints only the
487
+ final answer. Preserve the input across invocations; each new command identity
488
+ can start new work. This slice replaces the obsolete response-owned NDJSON
489
+ `streamTurn` API. Use the independent progress subscription below.
490
+
491
+ ## Progress and Session events
492
+
493
+ Progress and events reach the application over a Session connection, not over
494
+ a per-Session stream: `conn.send(text, { onProgress })` reports a message's draft,
495
+ and `session.connect({ onEvent, cursor, onCursor })` follows every event of the
496
+ Session with a resumable cursor (see [Connecting a Session](#connecting-a-session)).
497
+ `waitForResult(inputId)` and `turn()` stay available over plain HTTP for a
498
+ process that keeps no connection, for example to recover a result after a restart.
499
+
500
+ `asKnownSessionEvent(event)` narrows an event to the types the SDK knows: Turn
501
+ lifecycle, drafts, progress, saved messages and Operation steps, Client Tool
502
+ calls, Realtime transcripts and responses, Template switches and
503
+ `agent.session.idle`. It returns `undefined` for anything else, which is still
504
+ delivered unchanged. A draft (`agent.session.turn.output_text.snapshot`) is a
505
+ complete snapshot: replace what you showed, including with an empty reset.
506
+
507
+ ## Connecting a Session
508
+
509
+ `session.connect()` attaches a Session to this process's Backend Connection, one
510
+ WebSocket per Application Server process to the core (ADR 0109 of the service).
511
+ It is meant for a backend that stays attached to a Session, for example while
512
+ the user's device is online: it receives the Session's events, and it declares
513
+ and executes the Session's Client Tools. Each connection is one row in the
514
+ core and costs no polling, so attach when the device comes online.
515
+
516
+ ```ts
517
+ const conn = await organization.user(userId).session(sessionId).connect({
518
+ tools: lampTools(deviceId), // see below
519
+ cursor: await db.getCursor(deviceId), // resume after a restart
520
+ onCursor: (cursor) => db.saveCursor(deviceId, cursor),
521
+ onTranscript: (t) => app.push(deviceId, { role: "user", text: t.text }),
522
+ onResponse: (r) => app.push(deviceId, { role: "assistant", text: r.text }),
523
+ onEvent: (event) => forward(event), // every event, in order
524
+ });
525
+ conn.closed.then((end) => console.log("connection ended", end.reason));
526
+ // ...when the device goes offline:
527
+ await conn.close();
528
+ ```
529
+
530
+ - **Events.** Callbacks run one event at a time, in the Session's order, and
531
+ `onCursor` follows once an event's callbacks have completed. Persist that
532
+ cursor and pass it back as `cursor` to replay what was missed; without one,
533
+ delivery starts after the Session's latest event and history is not replayed.
534
+ `onTranscript` and `onResponse` receive completed Realtime transcripts and
535
+ responses; `onEvent` receives every event, including types the SDK does not
536
+ know.
537
+ - **Reconnection.** The SDK opens Backend Connections on demand, holds up to the
538
+ `maxSessions` the core announces on each (1024 today) and opens another when
539
+ one is full. After a lost connection, or when the core asks a connection to
540
+ drain before a restart, it re-attaches each Session on another connection from
541
+ the last processed event, without gaps or repeats. A connection that holds
542
+ nothing closes after a few seconds. Liveness is checked with an application
543
+ `ping` when a connection is silent.
544
+ - **One executor per Session.** A connection attaches for execution by default.
545
+ Another process's connection to the same Session takes it over, and this one
546
+ ends with `closed` resolving `{ reason: "superseded" }`. One `OrganizationClient`
547
+ holds at most one connection per Session; reuse it. `observe: true` attaches
548
+ for events only, never takes the Session over and cannot declare tools.
549
+ - **Ends.** `closed` resolves once: `closed` after `close()` or
550
+ `organization.close()`, `superseded`, `session_deleted`, or `error` when the
551
+ connection cannot be restored, for example after the Organization API Key was
552
+ revoked (a `BackendConnectionClosedError` with code 4001). A refused attach
553
+ rejects `connect()` with a `BackendRequestError` whose `reason` is the core's,
554
+ such as `session_not_found`.
555
+ - **Errors in your callbacks** do not stop delivery. They go to `onError`;
556
+ without it they are rethrown asynchronously, which by default ends a Node.js
557
+ process.
558
+
559
+ ### Client Tools on a connection
560
+
561
+ `tools` maps each Client Tool name to its declaration and a `run` function. Each
562
+ attach replaces the Session's whole Client Tool set with these declarations, and
563
+ the connection executes their calls:
564
+
565
+ ```ts
566
+ const lampTools = (deviceId: string): ClientToolHandlers => ({
567
+ set_light: {
568
+ description: "Turn the lamp on or off, or set its brightness",
569
+ parameters: {
570
+ type: "object",
571
+ properties: {
572
+ on: { type: "boolean" },
573
+ brightness: { type: "integer", minimum: 1, maximum: 100 },
574
+ },
575
+ },
576
+ run: async (args, { signal }) => {
577
+ // Fail at once when the device is offline, so the model does not wait.
578
+ const state = await askDevice(deviceId, "set_light", args, { signal });
579
+ return state.on ? `On at ${state.brightness}%` : "Off";
580
+ },
581
+ },
582
+ });
583
+ ```
584
+
585
+ - **Claim before running.** For each call the SDK first asks the core for the
586
+ right to execute it and runs `run` only when the core grants it, so a call the
587
+ core reports as not performed was never executed. A call that nobody claims
588
+ within the core's claim window (5 seconds) is reported to the model as not
589
+ performed.
590
+ - **Results.** A returned string is what the model receives; other values are
591
+ sent as JSON. A thrown error becomes a failure whose message the model sees.
592
+ A tool the connection has no handler for fails with a message saying so.
593
+ Results go back over any Backend Connection of the Organization and are
594
+ retried after a lost connection until the call's deadline.
595
+ - **Once per call.** A `callId` runs at most once per connection, even when the
596
+ core sends it again after a reconnect. If the core's answer to a claim is
597
+ lost, the SDK does not run the call and closes it as failed.
598
+ - **Deadline.** `run` receives `{ callId, sessionId, userId, turnId, name,
599
+ deadlineAt, signal }`. At `deadlineAt`, 60 seconds after the call was
600
+ published, `signal` aborts and the SDK reports a timeout; the core then tells
601
+ the model the effect is unknown.
602
+ - **After `close()`** calls already running finish and their results are still
603
+ submitted; later calls are not received.
604
+
605
+ A result the core does not accept, for example after the call's deadline,
606
+ reaches `onError` as a `ClientToolResultRefusedError` with the core's `reason`.
607
+ Calls are answered only on the executing connection; the `claimBy` they carry in
608
+ `requires_action` events marks them as such.
609
+
610
+ ### Messages and voice on a connection
611
+
612
+ `conn.send(text, options)` sends a user message over the connection and resolves
613
+ with the same result as `session.turn()`, once the Turn finishes:
614
+
615
+ ```ts
616
+ const reply = await conn.send(text, {
617
+ clientEventId: savedMessageId, // optional; persist it to recover after a crash
618
+ onProgress: (draft) => app.show(deviceId, draft),
619
+ });
620
+ return reply.text;
621
+ ```
622
+
623
+ - The message starts a Turn on an idle Session or steers the active one; the
624
+ call waits for that Turn either way. Rejections, failures, interruptions and
625
+ local timeouts raise the same errors as `session.turn()`: `InputNotAppliedError`,
626
+ `TurnFailedError`, `TurnInterruptedError` and `TurnWaitTimeoutError` (default
627
+ `timeoutMs` 60 seconds, which never cancels the Turn).
628
+ - The call resolves once the Session has also released the Turn
629
+ (`agent.session.idle`), so the next `send()`, audio Turn or Template switch
630
+ starts at once instead of steering a finished Turn or being refused as busy.
631
+ The release normally follows the result at once; after 5 seconds without it
632
+ the result is returned anyway.
633
+ - Progress comes from the pushed events, without polling. `onProgress` receives
634
+ the complete draft each time it grows (today every 1,024 characters): replace
635
+ what you showed, do not append. When the Turn finishes, the SDK reads the
636
+ settled result once over REST.
637
+ - `clientEventId` shares the Session's command namespace with REST. Without
638
+ one the SDK generates a UUID. If a reconnect loses the core's answer, the SDK
639
+ sends the message again with the same `clientEventId`, which the core
640
+ deduplicates; if it cannot, the call raises `OutcomeUnknownError` with
641
+ `inputId`, and `session.waitForResult(inputId)` recovers the result.
642
+ - A refusal of the message command, such as `session_read_only` or
643
+ `turn_admission_closed`, raises `BackendRequestError` with that code as
644
+ `reason` and the REST `details`.
645
+
646
+ `conn.realtimeGrant({ audio?, metadata? })` prepares the Session's Realtime
647
+ connection and returns `{ websocketUrl, connectToken, connectTokenExpiresAt }`
648
+ for a device to join it directly. When a Realtime connection is already live it
649
+ returns a new token for that one, and `audio` is not applied. The token is short
650
+ lived; ask for a new grant each time the device wants to talk.
651
+
652
+ ### Example: a smart lamp
653
+
654
+ The packaged `lamp-backend.mjs` example is the backend of a lamp that users talk
655
+ to by typing in an app or by speaking to the lamp, with one Session per lamp so
656
+ both share a conversation. `createLampBackend({ baseUrl, organizationApiKey,
657
+ templateId, device, store })` returns:
658
+
659
+ - `bindDevice(appUserId, deviceId)`: one End User per app user, one Session per lamp.
660
+ - `onDeviceOnline(deviceId)` / `onDeviceOffline(deviceId)`: keep the lamp's Session
661
+ connected while it is online, with the lamp's tools `set_light`, `get_status`
662
+ and `set_timer`, which forward each call to the lamp over your device channel.
663
+ - `onAppMessage(deviceId, text, { onDraft })`: a typed message; when the lamp is
664
+ offline a temporary connection is used and the lamp's tools fail at once.
665
+ - `onDeviceWantsToTalk(deviceId)`: sends the lamp a Realtime grant.
666
+
667
+ `device` is your channel to the lamps: `isOnline(deviceId)`,
668
+ `request(deviceId, command, args, { signal })` and `send(deviceId, topic,
669
+ payload)`. Wired to MQTT, with the lamp announcing itself through a retained
670
+ presence message:
671
+
672
+ ```js
673
+ const lamps = createLampBackend({ baseUrl, organizationApiKey, templateId, device: mqttDevices(broker) });
674
+ broker.subscribe("lamp/+/presence");
675
+ broker.subscribe("lamp/+/talk");
676
+ broker.on("message", (topic, payload) => {
677
+ const [, deviceId, kind] = topic.split("/");
678
+ if (kind === "presence")
679
+ payload.toString() === "online" ? lamps.onDeviceOnline(deviceId) : lamps.onDeviceOffline(deviceId);
680
+ if (kind === "talk") lamps.onDeviceWantsToTalk(deviceId);
681
+ });
682
+ app.post("/devices/:id/messages", async (req, res) =>
683
+ res.json({ text: await lamps.onAppMessage(req.params.id, req.body.text) }));
684
+ ```
685
+
686
+ The lamp itself does not use the SDK. Its firmware, in pseudo-code:
687
+
688
+ ```c
689
+ mqtt_connect(broker, will = { "lamp/{id}/presence", "offline", retain });
690
+ mqtt_publish("lamp/{id}/presence", "online", retain);
691
+
692
+ on_message("lamp/{id}/cmd", msg) { // a Client Tool call, forwarded
693
+ switch (msg.command) {
694
+ case "set_light": apply(msg.args); reply(msg.requestId, ok, state()); break;
695
+ case "get_status": reply(msg.requestId, ok, { ...state(), ambientLux: lux() }); break;
696
+ case "set_timer": timer_start(msg.args.minutes * 60, led_off); reply(msg.requestId, ok, {}); break;
697
+ default: reply(msg.requestId, error, "unknown command");
698
+ }
699
+ }
700
+
701
+ on_button_press() { mqtt_publish("lamp/{id}/talk", ""); }
702
+
703
+ on_message("lamp/{id}/voice_grant", grant) { // audio goes to TiDB Link directly
704
+ ws = websocket_connect(grant.websocketUrl, grant.connectToken);
705
+ while (ws.open) { ws.send(mic_read(20ms)); if (ws.has_audio()) speaker_play(ws.recv()); }
706
+ }
707
+ ```
708
+
709
+ Cloud capabilities such as a warranty lookup belong in a Connector the
710
+ Template references (see [Connectors](#connectors)), not in the lamp's tools.
711
+
712
+ The connection uses the global `WebSocket` of Node.js 22, which sends the
713
+ Organization API Key in the upgrade's `Authorization` header and never in the
714
+ URL. Pass `WebSocket` to `OrganizationClient` to use another implementation; it
715
+ must accept `new WebSocket(url, { protocols, headers })`.
716
+
717
+ ## Session Client Tools
718
+
719
+ Declare the application functions that a Turn may request. `connect({ tools })`
720
+ declares them on every attach; the REST methods below read or replace the set
721
+ without a connection. Parameters are JSON Schema objects. The service validates reserved
722
+ names, schema size/depth and combined tool limits; the `async` argument is reserved
723
+ for the platform and is not part of application arguments.
724
+
725
+ ```ts
726
+ import type { ClientToolDeclaration } from "@tidblink/server";
727
+ const tools: ClientToolDeclaration[] = [{
728
+ type: "function",
729
+ name: "lookup_order",
730
+ description: "Read an order status",
731
+ parameters: {
732
+ type: "object",
733
+ properties: { orderId: { type: "string" } },
734
+ required: ["orderId"],
735
+ additionalProperties: false,
736
+ },
737
+ }];
738
+ const session = await customer.createSession({
739
+ templateId: savedTemplateId,
740
+ idempotencyKey: savedSessionCreationKey,
741
+ tools,
742
+ });
743
+ const current = await session.getClientTools();
744
+ await session.replaceClientTools(tools, { ifMatch: current.etag });
745
+ ```
746
+
747
+ Replacement is the complete set, with `[]` clearing it. Its ETag is independent of
748
+ the Session's model ETag. Stale replacement returns `precondition_failed`; read the
749
+ set and decide again. A Turn retains the declarations admitted when it started.
750
+ An identical replacement keeps its revision. Text, voice and prerecorded-audio Turns
751
+ use the Session's declarations; Scheduler-started Turns do not.
752
+
753
+ An executing Session connection runs the calls of these tools; see
754
+ [Client Tools on a connection](#client-tools-on-a-connection). There is no REST
755
+ path to answer a call.
756
+
757
+ ## Saved conversation history
758
+
759
+ ```ts
760
+ const interactions = await session.listInteractions({ limit: 20 });
761
+ const turns = await session.history({ limit: 20 });
762
+ const turn = await session.getTurn(result.turnId);
763
+ const timeline = await session.timeline({ limit: 20 });
764
+ ```
765
+
766
+ Each list returns its named array (`interactions`, `turns`, or `entries`) and
767
+ `hasMore` / `nextCursor`. Limits are 1–200, default 50. Continue with the opaque
768
+ cursor in the same User, Session and list; the SDK does not silently flatten or
769
+ refetch pages. Interactions are in ascending Session-local sequence. Turns are
770
+ in creation-time order and preserve Full Agent and user-input Realtime
771
+ projections, including input context, saved multiple user Messages, Operations,
772
+ failure details and interrupted speech metadata. Narration-only Realtime facts
773
+ remain in Timeline and associated `realtimeResponses` rather than independent
774
+ public Turns.
775
+
776
+ Timeline combines Turn snapshots and durable Realtime facts in event order. A
777
+ Turn moved behind the Realtime input that started it can appear again after a
778
+ cursor; replace entries by Turn ID or Realtime fact ID. Late Realtime facts can
779
+ precede an already consumed Turn-list cursor; refresh Turn history or use
780
+ Timeline for durable ordering. History is readable content, not authority for an
781
+ original request's result: use its interaction receipt for that outcome.
782
+
783
+ ## Steering, cancellation and checkpoint retry
784
+
785
+ Send another `submitMessage` while a Turn is active to add requirements. Its
786
+ receipt reports `mode: "steered"` and the same `turnId`; `appliedAt` records actual
787
+ incorporation. This is the service's ordinary message admission, so a message
788
+ arriving after completion may start a new Turn. No target-locked steering command
789
+ is offered by this service. Inspect the receipt rather than assuming which Turn
790
+ accepted the message.
791
+
792
+ Cancellation targets an exact Turn and has its own stable command identity:
793
+
794
+ ```ts
795
+ const stop = await session.cancel({
796
+ clientEventId: savedCancellationId,
797
+ turnId: original.turnId,
798
+ });
799
+ const cancellation = await session.getInteraction(stop.inputId);
800
+ ```
801
+
802
+ Acceptance requests a stop; it does not establish termination or undo completed
803
+ effects. Read the cancellation receipt or the original message receipt until its
804
+ `result.status` is terminal. A completed Turn can retain success if completion won
805
+ the race. A stale target is refused rather than cancelling its successor.
806
+
807
+ Checkpoint retry deliberately starts another execution of the selected Turn:
808
+
809
+ ```ts
810
+ const retry = await session.retry({
811
+ clientEventId: savedRetryId,
812
+ turnId: original.turnId,
813
+ });
814
+ const retried = await session.waitForResult(retry.clientEventId);
815
+ const earlier = await session.getInteraction(original.inputId);
816
+ ```
817
+
818
+ The retry acceptance contains `{clientEventId, sessionId, turnId, disposition:
819
+ "retrying"}`. Its durable interaction is readable using `clientEventId`, including
820
+ through `reconcileInteraction`. Waiting accepts message and retry identities; a
821
+ successful saved answer must match that interaction's execution completion time.
822
+ Earlier failed or interrupted receipts keep their original outcome even when the
823
+ Turn later succeeds. A cancellation receipt is not an answer receipt.
824
+
825
+ Retry is disabled by default in Agent Service. `checkpoint_runtime_unavailable`
826
+ and other eligibility or authority refusals remain `TiDBLinkApiError`; the SDK
827
+ never substitutes a new message. An uncertain cancellation or retry preserves its
828
+ Session, command and Turn IDs in `OutcomeUnknownError`. Read the original receipt
829
+ before deciding whether to repeat the identical command. A new ID is a new
830
+ request, not network recovery.
831
+
832
+ The installed `@tidblink/server/examples/control-turn.mjs` exports
833
+ `controlTurnWorkflow`. It returns cancellation acceptance for `action: "cancel"`
834
+ and waits for the separate retry result for `action: "retry"`. Run it with
835
+ `AGENT_SERVICE_URL`, `ORGANIZATION_API_KEY`, `END_USER_ID`, `SESSION_ID`, `TURN_ID`, `CLIENT_EVENT_ID` and
836
+ `TURN_ACTION` set; retain those identities before dispatch. This example never
837
+ chooses to retry automatically.
838
+
839
+ ## Realtime connection preparation
840
+
841
+ A backend can discover the published Realtime Models and prepare an existing
842
+ owned Session for a terminal. The SDK returns the one-time connect token but does
843
+ not open or implement the WebSocket transport.
844
+
845
+ ```ts
846
+ import { OrganizationClient } from "@tidblink/server";
847
+
848
+ const user = new OrganizationClient({
849
+ baseUrl: process.env.AGENT_SERVICE_URL!,
850
+ apiKey: process.env.ORGANIZATION_API_KEY!,
851
+ }).user(process.env.END_USER_ID!);
852
+ const catalog = await user.listRealtimeModels();
853
+ const session = user.session(process.env.SESSION_ID!);
854
+ const current = await session.getRealtimeModelSelection();
855
+ const selected = catalog.models.find((model) => model.available);
856
+ if (selected && selected.id !== current.model) {
857
+ await session.setRealtimeModel(selected.id, { ifMatch: current.etag });
858
+ }
859
+ const prepared = await session.openRealtimeConnection({
860
+ metadata: { terminal: "kiosk-1" },
861
+ });
862
+ // Hand prepared.websocketUrl, prepared.connectToken and
863
+ // prepared.connectTokenExpiresAt directly to the intended terminal.
864
+ ```
865
+
866
+ `setRealtimeModel` requires the independent ETag returned by
867
+ `getRealtimeModelSelection`; a stale ETag raises `ConflictError` with code
868
+ `realtime_model_conflict`. A selection change applies to the next Realtime
869
+ Connection. A live connection keeps the model and provider identity frozen at
870
+ its admission. Model availability, Session voice eligibility, language support,
871
+ audio support and one-live-connection admission remain service decisions.
872
+
873
+ Use `getRealtimeConnection()` to inspect the newest connection,
874
+ `createRealtimeConnectToken()` to mint another one-time credential, and
875
+ `endRealtimeConnection()` to terminate it. Connect tokens expire at the returned
876
+ `connectTokenExpiresAt` and cannot be recovered from later reads. The SDK does
877
+ not cache them. Actual media transport, audio capture/playback, codecs, browser
878
+ support and device connections are outside this package slice.
879
+
880
+ The packaged example accepts an existing Session ID and never prints the token:
881
+
882
+ ```js
883
+ import { prepareRealtimeConnection } from "@tidblink/server/examples/prepare-realtime-connection.mjs";
884
+
885
+ const prepared = await prepareRealtimeConnection({
886
+ baseUrl: process.env.AGENT_SERVICE_URL,
887
+ organizationApiKey: process.env.ORGANIZATION_API_KEY,
888
+ userId: process.env.END_USER_ID,
889
+ sessionId: process.env.SESSION_ID,
890
+ realtimeModelId: process.env.REALTIME_MODEL_ID,
891
+ });
892
+ ```
893
+
894
+ Set `AGENT_SERVICE_URL`, `ORGANIZATION_API_KEY`, `END_USER_ID`, and `SESSION_ID` to run the example
895
+ directly. `REALTIME_MODEL_ID` is optional.
896
+
897
+ ## Prerecorded audio
898
+
899
+ An existing Session can accept a recording through its separate audio entry point.
900
+ The SDK sends multipart bytes and consumes that request's own response:
901
+
902
+ ```ts
903
+ import { readFile } from "node:fs/promises";
904
+
905
+ const result = await session.audio({
906
+ bytes: await readFile("recording.wav"),
907
+ filename: "recording.wav",
908
+ contentType: "audio/wav",
909
+ billingTag: null,
910
+ });
911
+ console.log(result.text);
912
+ ```
913
+
914
+ Supported recordings are mp3, mp4, mpeg, mpga, m4a, wav and webm. The request must
915
+ contain nonempty bytes no larger than 25 MiB. Transcription availability, codec
916
+ validity, budget and Session eligibility remain service decisions. Omit
917
+ `billingTag` to inherit the Session default, supply one key/value pair to override,
918
+ or use `null` to clear it for this Turn. The SDK encodes that optional field as
919
+ JSON without changing the file's bytes.
920
+
921
+ `audio()` returns an `AudioTurnResult` only after the same Turn emits its final
922
+ Message and successful terminal event. It throws `TurnFailedError` or
923
+ `TurnInterruptedError` for those terminal outcomes. `streamAudio()` exposes the
924
+ actual `AudioTurnEvent` sequence: `event`, `sessionId`, `turnId`, `seq`,
925
+ `createdAt`, optional `agentRunId`, and event-specific `payload`. An
926
+ `assistant_draft` contains a complete replacement snapshot, including an empty
927
+ reset; it is separate from `progress` and the final `assistant_message`.
928
+
929
+ **The audio request owns execution's response.** Breaking its event loop, aborting
930
+ its signal or losing the connection may request interruption. This differs from
931
+ closing a text observation subscription. EOF alone is never reported as success.
932
+ `AudioTurnStoppedError` preserves the Session and any already observed Turn ID;
933
+ inspect `getTurn(turnId)` or saved history before deciding what to submit next.
934
+ Audio accepts no `clientEventId`, has no ordinary text interaction receipt and
935
+ provides no idempotent resubmission or event-position recovery promise. The SDK
936
+ does not resubmit a recording automatically. A transcription refusal may still
937
+ have consumed provider work; it does not imply a refunded charge.
938
+
939
+ The installed `submit-audio.mjs` example reads an existing file and prints the
940
+ final answer. Set `AGENT_SERVICE_URL`, `ORGANIZATION_API_KEY`, `END_USER_ID`, `SESSION_ID` and
941
+ `AUDIO_FILE_PATH`; `AUDIO_CONTENT_TYPE` is optional:
942
+
943
+ ```sh
944
+ node node_modules/@tidblink/server/examples/submit-audio.mjs
945
+ ```
946
+
947
+ The exported `submitAudioWorkflow()` accepts bytes and an optional abort signal.
948
+ This SDK does not capture, play, encode or bridge audio.
949
+
950
+ ## File attachments
951
+
952
+ `user.files` manages owned uploaded files. Upload from a `Uint8Array` (including
953
+ Node.js `Buffer`) and retain a stable creation key with the exact bytes and name.
954
+ The service chooses inline or multipart transfer; the SDK supplies complete and
955
+ per-part SHA-256 digests, transfers the required bytes, and confirms whole-file
956
+ verification before returning a ready file. Empty files are supported.
957
+
958
+ ```ts
959
+ import { readFile } from "node:fs/promises";
960
+
961
+ const file = await customer.files.upload({
962
+ originalName: "notes.txt",
963
+ bytes: await readFile("notes.txt"),
964
+ contentType: "text/plain",
965
+ idempotencyKey: savedUploadKey,
966
+ });
967
+ const result = await session.turn({
968
+ clientEventId: savedMessageId,
969
+ text: "Summarize the attached notes.",
970
+ userFileIds: [file.fileId],
971
+ });
972
+ const downloaded = await customer.files.download(file.fileId);
973
+ ```
974
+
975
+ Only ready files authorized for the Session's User may be attached. The service
976
+ checks that authority at message admission; an earlier local readiness check
977
+ cannot grant access to another User's file. File bytes remain separate from the
978
+ message text. `capabilities()` exposes current upload limits; a typed 413 requires
979
+ reading them again and explicitly deciding whether to create another upload.
980
+
981
+ For explicit recovery use `createUpload()` with the original metadata and key,
982
+ `getUpload(uploadId)`, `uploadContent()`, `presignPart()` / `uploadPart()`,
983
+ `completeUpload()`, and `cancelUpload()`. Retain each returned part number, ETag
984
+ and digest until completion. A status read never creates or transfers anything.
985
+ Create replay retains the original file and fixed upload expiry; it may renew a
986
+ short-lived inline transfer URL. An expired or failed upload requires a new key
987
+ and file. The SDK never chooses that new identity or repeats a failed transfer
988
+ automatically.
989
+
990
+ `FileUploadOutcomeUnknownError` carries `idempotencyKey` and, once creation was
991
+ acknowledged, `uploadId`. Read that upload before deciding what to repeat. A lost
992
+ creation response can be reconciled with `createUpload()` using the exact
993
+ original metadata and key. A pending multipart part can be signed again for its
994
+ original digest; after an uncertain completion, read status rather than assuming
995
+ failure or sending the message. Cancellation marks the incomplete file unusable;
996
+ it does not delete a previously accepted conversation.
997
+
998
+ `list({ history, limit, cursor })` returns `{ files, nextCursor }`; it has no
999
+ `hasMore` field. `get(fileId)` reads metadata. `getContent(fileId)` returns either
1000
+ `{ type: "bytes", bytes, contentType }` or, for a large file,
1001
+ `{ type: "download", grant }`. `downloadFromGrant(grant)` redeems the latter;
1002
+ `download(fileId)` handles both forms and verifies complete size and SHA-256.
1003
+ A new authorized content read obtains a fresh grant after expiry. Upload and
1004
+ download grants are bearer credentials: do not log or store their URLs beyond
1005
+ need. Transfers send only the grant's required headers, never an API Key.
1006
+ Uploads reject redirects; a download may follow one HTTPS redirect without an
1007
+ API Key. Files are held in memory while hashing or downloading, so size your
1008
+ backend's concurrency for the service's advertised maximum.
1009
+
1010
+ The packaged `upload-file.mjs` example uploads and attaches one file. Set
1011
+ `AGENT_SERVICE_URL`, `ORGANIZATION_API_KEY`, `END_USER_ID`, `SESSION_ID`, `FILE_PATH`,
1012
+ `UPLOAD_IDEMPOTENCY_KEY`, and `CLIENT_EVENT_ID`, then run:
1013
+
1014
+ ```sh
1015
+ node node_modules/@tidblink/server/examples/upload-file.mjs
1016
+ ```
1017
+
1018
+ The example returns the final conversation result; its exported
1019
+ `uploadFileWorkflow()` accepts bytes directly. It does not manage general Drive
1020
+ directories or write Session text files. Organization owner-scoped clients can
1021
+ upload, read and attach the same ordinary Files.
1022
+
1023
+ ## Errors and uncertain outcomes
1024
+
1025
+ Organization, text/progress/history and Session lifecycle methods accept
1026
+ `{ requestId?, signal? }` options; creation and list options include these fields.
1027
+ `TiDBLinkApiError` preserves status, service code, request identity and details;
1028
+ `ConflictError` preserves explicit 409/412 conflicts. The service's response
1029
+ request identity takes precedence over the one sent.
1030
+
1031
+ Safe reads, keyed Session creation and Template switches use bounded retries
1032
+ with the same request identity and key. User creation,
1033
+ Session updates, deletion and Realtime mutations are never
1034
+ automatically replayed. Disconnects, unusable success responses, HTTP 408 and
1035
+ server failures during these mutations raise `OutcomeUnknownError`. For an HTTP failure, its
1036
+ `cause` retains the `TiDBLinkApiError`. `AbortSignal` stops the local request;
1037
+ after dispatch it does not establish whether a mutation happened. Redirects are
1038
+ refused.
1039
+
1040
+ After uncertain End User creation, repeat it with the same `externalId`.
1041
+
1042
+ After uncertain Session creation or Template switch, repeat the original input
1043
+ with the same key. After an uncertain name, tag or model update, read the
1044
+ resource before deciding on another change. Deletion can hide a
1045
+ resource from ordinary reads, so a missing resource does not recover its original
1046
+ mutation receipt.
1047
+
1048
+ After an uncertain Realtime Model update, read the selection again. After an
1049
+ uncertain connection open or end, inspect the newest connection. An uncertain
1050
+ connect-token response cannot recover that plaintext token; deliberately mint a
1051
+ new token after reconciling the connection state.
1052
+
1053
+ ## User usage
1054
+
1055
+ `organization.getUserUsage(userId, options)` and
1056
+ `organization.getUserUsageDetails(userId, options)` read one target User's usage.
1057
+
1058
+ | | |
1059
+ | --- | --- |
1060
+ | Range | Required exact UTC timestamps with milliseconds, inclusive `from` and exclusive `to`; at most 31 days |
1061
+ | Buckets | `interval: "hour"`, `"day"`, `"week"` or `"month"` |
1062
+ | Details | Globally ordered `items`; optional `kind`, `limit` (1 to 500) and opaque `cursor`; `nextCursor: null` ends the stream |
1063
+
1064
+ Both support an exact `tagKey`/`tagValue` pair. Send both or neither. Values retain
1065
+ case sensitivity and the service's normalization rules. Both also accept
1066
+ `templateId`, which keeps only usage admitted under that Template; each detail
1067
+ item reports it as `agentTemplateId`. Preserve the original range, kind, Template
1068
+ and tag filters when following a cursor.
1069
+
1070
+ ```ts
1071
+ const page = await organization.getUserUsageDetails(targetUserId, {
1072
+ from: "2030-01-02T00:00:00.000Z",
1073
+ to: "2030-01-03T00:00:00.000Z",
1074
+ limit: 100,
1075
+ });
1076
+ ```
1077
+
1078
+ The SDK returns the service's values unchanged. `costMicros: null`,
1079
+ `pricingStatus: "unknown"`, `valuationStatus: "unpriced"`, and incomplete fact or
1080
+ valuation counts retain different meanings. A stored numeric cost does not make
1081
+ an unpriced fact billable. Priced totals exclude unpriced facts. Scalar cost totals describe platform-borne
1082
+ costs; `costByBearer` preserves the separate platform, customer and unknown
1083
+ categories. Public-list estimates are not supplier invoices or known final charges. Fact completeness and
1084
+ valuation completeness are independent. Do not display missing costs as zero or
1085
+ infer a balance from these reads.
1086
+
1087
+ The installed example `@tidblink/server/examples/read-usage.mjs` exports
1088
+ `readUserUsage`, returning a summary and the first detail page. The CLI requires
1089
+ `AGENT_SERVICE_URL`, `ORGANIZATION_API_KEY` and `END_USER_ID`. Supply
1090
+ `USAGE_FROM`/`USAGE_TO` as exact UTC timestamps;
1091
+ `TAG_KEY`/`TAG_VALUE` are optional. It performs reads only and does not claim that
1092
+ a first detail page is complete history.
1093
+
1094
+ ## Compatibility and verification
1095
+
1096
+ `TIDB_LINK_REVISION` / `tidbLinkRevision` identify the selected exact service
1097
+ revision for every supported capability. `tidbLinkOpenApiSha256` pins that
1098
+ service's `docs/openapi.yaml`. The former per-feature revision constants and
1099
+ package fields have been removed; use these shared values instead.
1100
+ `TIDB_LINK_API_VERSION` remains `0.0.1`; the version string alone does not establish
1101
+ compatibility. This is a selective surface, not coverage of every service API.
1102
+
1103
+ `npm run test:service-contract` checks frozen selected SDK OpenAPI subsets from
1104
+ that exact revision. The committed snapshot and metadata retain the source
1105
+ fingerprint and immutable contract evidence; no live synchronization is required.
1106
+ The snapshot alone does not certify service behavior.
1107
+
1108
+ `npm test` covers the simulated HTTP failure boundary and installs the actual
1109
+ packed artifact in a clean ESM TypeScript consumer. To verify the packed SDK and
1110
+ its examples against the real service, use the service's Node.js 26 runtime, install its
1111
+ pinned dependencies, generate Prisma, build its Hardware contract and prepare its
1112
+ current schema in a **dedicated test database**. See the selected service's
1113
+ [current-schema setup](https://github.com/mem9-ai/agent-stack/blob/4305ef67daebf23c416badf806217a7c11dcef37/docs/prisma-first-tidb-validation-and-rollout.md).
1114
+
1115
+ ```sh
1116
+ AGENT_SERVICE_ROOT=/path/to/agent-stack \
1117
+ TEST_TIDB_DATABASE_URL=mysql://root@127.0.0.1:4000/tidb_link_sdk_test \
1118
+ TIDB_SSL=off npm run test:service
1119
+ ```
1120
+
1121
+ This command requires a clean service checkout at the exact revision. It builds
1122
+ the service for the compiled Organization consumer and uses a dedicated TiDB
1123
+ connection. Run just that consumer with:
1124
+
1125
+ ```sh
1126
+ AGENT_SERVICE_ROOT=/path/to/agent-stack \
1127
+ TEST_TIDB_DATABASE_URL=mysql://root@127.0.0.1:4000/tidb_link_sdk_test \
1128
+ TIDB_SSL=off npm run test:service -- organization/organization.compiled.fixture.ts
1129
+ ```
1130
+
1131
+ The compiled fixture reuses the service system harness, its isolated database,
1132
+ controlled model/storage/ASR/voice dependencies and real background runner. A
1133
+ separate process installs the tarball in a clean directory and calls only the
1134
+ installed SDK and packaged Organization example for its application workflow.
1135
+ It verifies Organization provisioning, discovery, Session
1136
+ creation replay, owner isolation, file upload/download and message attachment,
1137
+ text results and history, Client Tools on a Backend Connection, Connector registration and a Session's call
1138
+ to the example HTTP Connector, audio,
1139
+ Realtime HTTP grants, rotation/revocation and fresh-authority recovery. Management credential changes
1140
+ and controlled suppliers are fixture actions, not claimed SDK APIs. WebSocket
1141
+ redemption, live speech suppliers, physical hardware and deployment compatibility
1142
+ are outside this SDK proof.
1143
+
1144
+ The remaining fixtures use the service's existing AppModule test seam and reset
1145
+ all rows in the selected dedicated test database before and after their runs.
1146
+ They use genuine credentials, real TiDB and controlled dependencies. Their scope:
1147
+ Fixtures prepare identities, Templates and eligible
1148
+ Realtime Sessions. A composed installed quickstart provisions one fresh User,
1149
+ discovers Templates/models, creates a Session, obtains a final answer
1150
+ and recovers the same original result. Public SDK methods also verify ownership,
1151
+ lifecycle updates, Template switches with and without a Handoff Turn, and Realtime
1152
+ operations. Text cases
1153
+ exercise actual acceptance, final success/failure/interruption, local timeout and
1154
+ abandoned-wait recovery, response-loss reconciliation, and paginated mixed
1155
+ history through public SDK methods. Client Tool cases declare and replace tools
1156
+ with ownership and stale-revision refusals. Backend Connection cases attach
1157
+ Sessions, run the real Runtime's Client Tool calls through claim and result,
1158
+ send messages with drafts, failures, timeouts and cancellation, resume from a
1159
+ cursor, supersede connections and grant Realtime tokens. A second compiled
1160
+ fixture runs two replicas behind a local load balancer: a Turn on one replica
1161
+ calls the tool of an attachment on the other, a draining replica moves the
1162
+ attachment to the other one, a dropped connection replays its events, and
1163
+ another process supersedes the connection. A forwarding proxy drops only an actual service response to prove
1164
+ uncertain-submission recovery. Control cases
1165
+ verify active-Turn steering, eventual cancellation, disabled retry and an explicitly
1166
+ enabled checkpoint retry with an unchanged original outcome. The enabled fixture
1167
+ uses the existing controlled Drive9 and model suppliers. File cases use a separate
1168
+ fixture lifecycle with real upload state and a controlled Drive9/S3 byte supplier.
1169
+ They verify inline, multipart and empty uploads, cancellation and invalid content,
1170
+ large-file download-ticket redemption, and a real Runtime file read whose answer
1171
+ contains the uploaded content. The installed upload example exercises the same
1172
+ public workflow. Real supplier deployment certification is outside this evidence.
1173
+ Audio cases use the real transcription adapter with a controlled HTTP supplier.
1174
+ They cover recorded input, malformed/unsupported recordings, supplier refusals,
1175
+ model failure, active-response interruption and the installed audio example.
1176
+ The public Session projection exposes the frozen Template digest but hides the
1177
+ stored creation options. Those snapshots and active-Turn model freezing also rely
1178
+ on the selected service's [existing persistence evidence](https://github.com/mem9-ai/agent-stack/blob/4305ef67daebf23c416badf806217a7c11dcef37/test/product/sessions/sessions-withorgstore.tidb.spec.ts); SDK reads alone do not
1179
+ prove those hidden values.
1180
+ Usage cases seed controlled facts and verify both public projections, exact filters,
1181
+ paging and authority without executing messages to manufacture billing data.
1182
+ No production supplier credentials or WebSocket redemption are needed. A
1183
+ mismatch or unavailable service fails this check instead of silently skipping it.