@tidblink/server 0.3.0-rc.0 → 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 (124) hide show
  1. package/README.md +557 -368
  2. package/dist/audio.d.ts +9 -0
  3. package/dist/audio.d.ts.map +1 -1
  4. package/dist/audio.js +6 -1
  5. package/dist/audio.js.map +1 -1
  6. package/dist/backend-connection.d.ts +89 -0
  7. package/dist/backend-connection.d.ts.map +1 -0
  8. package/dist/backend-connection.js +300 -0
  9. package/dist/backend-connection.js.map +1 -0
  10. package/dist/backend-pool.d.ts +93 -0
  11. package/dist/backend-pool.d.ts.map +1 -0
  12. package/dist/backend-pool.js +419 -0
  13. package/dist/backend-pool.js.map +1 -0
  14. package/dist/client-tool-executor.d.ts +31 -0
  15. package/dist/client-tool-executor.d.ts.map +1 -0
  16. package/dist/client-tool-executor.js +190 -0
  17. package/dist/client-tool-executor.js.map +1 -0
  18. package/dist/client-tools.d.ts +0 -25
  19. package/dist/client-tools.d.ts.map +1 -1
  20. package/dist/client-tools.js +1 -67
  21. package/dist/client-tools.js.map +1 -1
  22. package/dist/connector-callback.d.ts +92 -0
  23. package/dist/connector-callback.d.ts.map +1 -0
  24. package/dist/connector-callback.js +155 -0
  25. package/dist/connector-callback.js.map +1 -0
  26. package/dist/connectors.d.ts +66 -0
  27. package/dist/connectors.d.ts.map +1 -0
  28. package/dist/connectors.js +71 -0
  29. package/dist/connectors.js.map +1 -0
  30. package/dist/control.js +1 -1
  31. package/dist/control.js.map +1 -1
  32. package/dist/errors.d.ts +34 -11
  33. package/dist/errors.d.ts.map +1 -1
  34. package/dist/errors.js +36 -8
  35. package/dist/errors.js.map +1 -1
  36. package/dist/events.d.ts +158 -0
  37. package/dist/events.d.ts.map +1 -0
  38. package/dist/events.js +110 -0
  39. package/dist/events.js.map +1 -0
  40. package/dist/files.js +9 -9
  41. package/dist/files.js.map +1 -1
  42. package/dist/history.d.ts +38 -1
  43. package/dist/history.d.ts.map +1 -1
  44. package/dist/history.js +1 -1
  45. package/dist/history.js.map +1 -1
  46. package/dist/http.d.ts +28 -4
  47. package/dist/http.d.ts.map +1 -1
  48. package/dist/http.js +44 -24
  49. package/dist/http.js.map +1 -1
  50. package/dist/index.d.ts +26 -13
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +10 -4
  53. package/dist/index.js.map +1 -1
  54. package/dist/organization.d.ts +45 -28
  55. package/dist/organization.d.ts.map +1 -1
  56. package/dist/organization.js +67 -73
  57. package/dist/organization.js.map +1 -1
  58. package/dist/realtime.d.ts +0 -1
  59. package/dist/realtime.d.ts.map +1 -1
  60. package/dist/realtime.js +0 -1
  61. package/dist/realtime.js.map +1 -1
  62. package/dist/session-connection.d.ts +144 -0
  63. package/dist/session-connection.d.ts.map +1 -0
  64. package/dist/session-connection.js +334 -0
  65. package/dist/session-connection.js.map +1 -0
  66. package/dist/session-send.d.ts +49 -0
  67. package/dist/session-send.d.ts.map +1 -0
  68. package/dist/session-send.js +179 -0
  69. package/dist/session-send.js.map +1 -0
  70. package/dist/session.d.ts +37 -8
  71. package/dist/session.d.ts.map +1 -1
  72. package/dist/session.js +56 -29
  73. package/dist/session.js.map +1 -1
  74. package/dist/skills.d.ts +103 -0
  75. package/dist/skills.d.ts.map +1 -0
  76. package/dist/skills.js +98 -0
  77. package/dist/skills.js.map +1 -0
  78. package/dist/sse.d.ts +5 -2
  79. package/dist/sse.d.ts.map +1 -1
  80. package/dist/sse.js +7 -2
  81. package/dist/sse.js.map +1 -1
  82. package/dist/template-switch.d.ts +51 -0
  83. package/dist/template-switch.d.ts.map +1 -0
  84. package/dist/template-switch.js +81 -0
  85. package/dist/template-switch.js.map +1 -0
  86. package/dist/templates.d.ts +129 -0
  87. package/dist/templates.d.ts.map +1 -0
  88. package/dist/templates.js +92 -0
  89. package/dist/templates.js.map +1 -0
  90. package/dist/turn.d.ts +4 -0
  91. package/dist/turn.d.ts.map +1 -1
  92. package/dist/turn.js +17 -13
  93. package/dist/turn.js.map +1 -1
  94. package/dist/usage.d.ts +13 -197
  95. package/dist/usage.d.ts.map +1 -1
  96. package/dist/usage.js +5 -3
  97. package/dist/usage.js.map +1 -1
  98. package/dist/user.d.ts +10 -51
  99. package/dist/user.d.ts.map +1 -1
  100. package/dist/user.js +18 -105
  101. package/dist/user.js.map +1 -1
  102. package/examples/control-turn.mjs +9 -4
  103. package/examples/create-session.mjs +37 -24
  104. package/examples/http-connector.mjs +127 -0
  105. package/examples/lamp-backend.mjs +175 -0
  106. package/examples/prepare-realtime-connection.mjs +18 -7
  107. package/examples/read-usage.mjs +7 -25
  108. package/examples/send-message.mjs +9 -4
  109. package/examples/submit-audio.mjs +9 -4
  110. package/examples/upload-file.mjs +9 -4
  111. package/package.json +7 -13
  112. package/dist/progress.d.ts +0 -34
  113. package/dist/progress.d.ts.map +0 -1
  114. package/dist/progress.js +0 -154
  115. package/dist/progress.js.map +0 -1
  116. package/dist/schedulers.d.ts +0 -129
  117. package/dist/schedulers.d.ts.map +0 -1
  118. package/dist/schedulers.js +0 -91
  119. package/dist/schedulers.js.map +0 -1
  120. package/examples/answer-client-tool.mjs +0 -69
  121. package/examples/observe-message.mjs +0 -44
  122. package/examples/organization-session.mjs +0 -66
  123. package/examples/provision-user.mjs +0 -83
  124. package/examples/schedule-task.mjs +0 -67
package/README.md CHANGED
@@ -6,18 +6,20 @@ stay on your backend; do not ship it to browsers or devices.
6
6
 
7
7
  ## Supported scope
8
8
 
9
- This package supports Organization information, End User provisioning, User API
10
- Key management, Template/model discovery, personal Agent and Session management,
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,
11
12
  text submission, steering, cancellation, checkpoint retry, original-result waiting,
12
- continuous progress, Client Tools, conversation history, file attachments, Schedulers,
13
+ continuous progress, Client Tools, Session connections over Backend Connections,
14
+ conversation history, file attachments,
13
15
  Realtime Model/Connection preparation, prerecorded audio and User usage reads for [service revision
14
- 88228780](https://github.com/mem9-ai/agent-stack/commit/882287809f91d9a3fdf60537460431d9f0fc23e7).
16
+ 4305ef67](https://github.com/mem9-ai/agent-stack/commit/4305ef67daebf23c416badf806217a7c11dcef37).
15
17
 
16
18
  Text commands return durable receipts independently of execution. Waiting reads the
17
- original command's outcome. Template version application, Memory configuration,
18
- default Agents and general Agent overrides are removed. Browser, MSP, management and device APIs are
19
- outside this package. Existing resource workflows accept either User authority or
20
- Organization authority with an explicit owner.
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.
21
23
 
22
24
  ## Quick start
23
25
 
@@ -29,29 +31,30 @@ npm install --save-exact @tidblink/server@next
29
31
 
30
32
  An application backend uses its Organization API Key (`ti_org_…`) and explicitly
31
33
  selects the ordinary End User with `organization.user(userId)`. The Key remains
32
- the caller; the User owns the resources. No User API Key is issued by this flow.
34
+ the caller; the User owns the resources.
33
35
 
34
36
  ```js
35
37
  import { randomUUID } from "node:crypto";
36
38
  import { OrganizationClient } from "@tidblink/server";
37
- import { organizationSessionWorkflow } from "@tidblink/server/examples/organization-session.mjs";
39
+ import { createSessionWorkflow } from "@tidblink/server/examples/create-session.mjs";
38
40
 
39
41
  const baseUrl = process.env.AGENT_SERVICE_URL;
40
42
  const organizationApiKey = process.env.ORGANIZATION_API_KEY;
41
43
  const organization = new OrganizationClient({ baseUrl, apiKey: organizationApiKey });
42
44
  const endUser = await organization.createEndUser({ externalId: "your-application-user-id" });
43
- // Retain this mapping. On later startup, load it instead of repeating creation.
45
+ // Safe to repeat: the same externalId returns the same End User.
44
46
  const user = organization.user(endUser.userId);
45
47
  const page = await user.listAgentTemplates();
46
48
  const template = page.templates[0];
47
49
  if (!template) throw new Error("Select an available Template; follow nextCursor if hasMore");
48
50
  const creation = {
49
51
  baseUrl, organizationApiKey, userId: endUser.userId,
50
- agentTemplateId: template.agentTemplateId,
51
- agentCreationKey: randomUUID(), sessionCreationKey: randomUUID(),
52
+ templateId: template.agentTemplateId,
53
+ sessionCreationKey: randomUUID(),
52
54
  };
53
- // Persist the selected Template, optional model and both creation identities.
54
- const { session } = await organizationSessionWorkflow(creation);
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);
55
58
  const message = { clientEventId: randomUUID(), text: "Help me plan a trip" };
56
59
  // Retain the Session, command identity and original input before dispatch.
57
60
  const receipt = await session.submitMessage(message);
@@ -60,149 +63,239 @@ console.log(result.text);
60
63
  const recovered = await session.waitForResult(message.clientEventId);
61
64
  ```
62
65
 
63
- `externalId` is unique within an Organization. A repeated `createEndUser` returns
64
- `resource_conflict`; recover the mapping with `listEndUsers` and the original
65
- `externalId`. Agent/Session creation replays require the same creation keys and
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
66
75
  inputs. Reading the original message receipt or waiting again does not execute
67
76
  another Turn. An HTTP request ID is diagnostic, not a creation identity.
68
77
 
69
- The installed Organization example can run with `AGENT_SERVICE_URL`,
70
- `ORGANIZATION_API_KEY`, `END_USER_ID`, `TEMPLATE_ID`, `AGENT_CREATION_KEY` and
71
- `SESSION_CREATION_KEY` (`MODEL_ID` is optional):
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):
72
81
 
73
82
  ```sh
74
- node node_modules/@tidblink/server/examples/organization-session.mjs
83
+ node node_modules/@tidblink/server/examples/create-session.mjs
75
84
  ```
76
85
 
77
- `organization.user(userId)` returns an `EndUserClient`. Its Agent, Session, file,
78
- Scheduler and catalog methods are the same resource methods used by `UserClient`.
79
- Keep usage reads on `organization.getUserUsage(userId, options)` and
80
- `getUserUsageDetails`; their range and response shapes differ from User-Key reads.
81
- Private MCP registration, general Drive entry management, sandbox preferences,
82
- Scheduler Plans and native Hardware APIs are not wrapped by this SDK. Use the
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
83
92
  pinned public HTTP contract for those existing service capabilities. Building ToD,
84
93
  a device gateway or management integration remains outside this package.
85
94
  Artifact APIs remain gated. The SDK prepares Realtime HTTP grants; applications
86
95
  own WebSocket/media transport. This change adds neither a generated full API
87
96
  client nor a media client.
88
97
 
89
- ### User-Key compatibility and migration
90
-
91
- Existing `new UserClient({ baseUrl, apiKey: userApiKey })` callers and the existing
92
- User-Key examples remain supported. To migrate resource workflows, replace that
93
- client with `new OrganizationClient({ baseUrl, apiKey: organizationApiKey }).user(userId)`.
94
- Retain the same resource IDs, creation inputs and command identities; do not issue
95
- a User Key to use Organization authority. The SDK sends the real Organization
96
- Key only to owner-scoped service endpoints and never as an owner identity header.
97
-
98
- The optional `provision-user.mjs` example still creates a User API Key on each
99
- successful call, including for an existing User. Its CLI requires
100
- `AGENT_SERVICE_URL`, `ORGANIZATION_API_KEY`, `END_USER_EXTERNAL_ID` and
101
- `USER_CREDENTIAL_FILE`; it reserves a new private file and refuses to overwrite it.
102
- A lost issuance response cannot recover a plaintext key from metadata.
98
+ ### Credential rotation
103
99
 
104
100
  After Organization Key rotation or revocation, create a new `OrganizationClient`
105
101
  with fresh valid authority and select the same User. Existing handles retain their
106
- old credential. New old-authority requests, subscriptions, Scheduler fires,
102
+ old credential. New old-authority requests, subscriptions,
107
103
  Realtime tokens and Organization-issued file tickets lose authority. Accepted
108
104
  Turns continue, and admitted file transfers may finish. Reconnect/obtain new
109
105
  grants with fresh authority and reconcile the original command before submitting
110
- new work. Updating retained Schedulers with fresh authority reauthorizes future
111
- fires. Another valid Key remains usable. Disabled Users cannot start new work;
106
+ new work. Another valid Key remains usable. Disabled Users cannot start new work;
112
107
  Organization authority can still read retained history/files and perform supported
113
- cleanup. User-Key ticket lifetime remains the service's separate compatibility
114
- contract. Application policies and K3 spending policy are separate concerns.
108
+ cleanup. Application policies and K3 spending policy are separate concerns.
115
109
 
116
- ## Key management
110
+ ### Custom transport
117
111
 
118
- Use these methods when your application also needs a User-Key client. Organization
119
- owner-scoped resource access does not require issuing this credential.
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).
120
116
 
121
- ```ts
122
- import { OrganizationClient } from "@tidblink/server";
117
+ ```js
123
118
  const organization = new OrganizationClient({
124
- baseUrl, apiKey: process.env.ORGANIZATION_API_KEY,
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,
125
143
  });
126
- const issued = await organization.createUserApiKey(endUser.userId, {
127
- name: "Optional User-Key client",
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,
128
148
  });
129
- const keys = await organization.listUserApiKeys(endUser.userId);
130
- const replacement = await organization.rotateUserApiKey(issued.apiKey.apiKeyId);
131
- // Save replacement.token; the previous token immediately stops authenticating.
132
- const revoked = await organization.revokeUserApiKey(issued.apiKey.apiKeyId);
133
149
  ```
134
150
 
135
- Create and rotate return `{ apiKey, token }`. List and revoke return metadata
136
- without the token or stored hash. Rotation preserves the key ID; revocation is
137
- idempotent. Only active ordinary End Users can own keys. Missing, foreign or
138
- disabled targets preserve the service's refusal.
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.
139
159
 
140
- The returned User API Key (`ti_user_…`) authenticates that User's public APIs.
141
- It grants no Organization management authority; `OrganizationClient` rejects it.
142
- An Organization key likewise grants no User identity.
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:
143
162
 
144
- ## Templates, Agents and Sessions
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
145
180
 
146
- Use `organization.user(userId)` or a User-Key `UserClient` to discover available Templates and deployment models. Model
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
147
232
  IDs are strings from the deployment catalog, including newly published models.
148
233
  `effectiveDefaultModel` is the create-time default; `defaultModel` is the
149
234
  service's canonical catalog default and may differ.
150
235
 
151
236
  ```ts
152
- import { UserClient } from "@tidblink/server";
237
+ import { OrganizationClient } from "@tidblink/server";
153
238
 
154
- const customer = new UserClient({
239
+ const organization = new OrganizationClient({
155
240
  baseUrl: process.env.AGENT_SERVICE_URL!,
156
- apiKey: process.env.USER_API_KEY!,
241
+ apiKey: process.env.ORGANIZATION_API_KEY!,
157
242
  });
243
+ const customer = organization.user(process.env.END_USER_ID!);
158
244
  const page = await customer.listAgentTemplates({ limit: 20 });
159
245
  const template = page.templates[0];
160
246
  if (!template) throw new Error("Select an available Template");
161
247
  const models = await customer.listModels();
162
- const agent = await customer.createAgent({
163
- agentTemplateId: template.agentTemplateId,
164
- idempotencyKey: "customer-agent-1",
165
- });
166
- await agent.update({ additionalInstructions: "Prefer concise answers." });
167
- const session = await agent.createSession({
248
+ const session = await customer.createSession({
249
+ templateId: template.agentTemplateId,
168
250
  idempotencyKey: "customer-conversation-1",
169
251
  model: models.effectiveDefaultModel,
170
252
  billingTag: { key: "account", value: "customer-1" },
253
+ additionalInstructions: "Prefer concise answers.",
254
+ sandbox: { workingDirectory: "projects/trip" },
171
255
  });
172
256
  await session.rename("Planning");
173
257
  await session.suggestName({ userText: "Plan a trip" }); // Generates and saves a name.
174
258
  await session.setBillingTag(null);
175
259
  ```
176
260
 
177
- Persist each creation key before calling. A new Agent key creates an independent
178
- Agent even for the same Template; the same key and Template recover the original
179
- Agent, including after unlinking. Reusing the key for another Template raises
180
- `ConflictError` with `agent_creation_conflict`. Session creation likewise requires
181
- a stable key and the same original input when recovering a creation. Its model,
182
- Realtime Model and Billing Tag selections participate in that identity. Do not
183
- generate a new key just because a response was lost.
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.
184
282
 
185
283
  `listAgentTemplates({ limit?, cursor? })` returns
186
- `{ templates, hasMore, nextCursor }`; `listSessions({ limit?, cursor? })` returns
187
- `{ sessions, hasMore, nextCursor }`, with `Session` handles in `sessions`.
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.
188
288
  Continue with the returned opaque cursor until `hasMore` is false, including
189
289
  Template pages with no available entries. A changed Template catalog can refuse
190
290
  continuation with `agent_template_list_changed`; restart discovery.
191
- Session cursors belong to their owning User and list. `listAgents()` returns a
192
- complete `Agent[]` without pagination. Use `getAgent(id)` and `getSession(id)` to
193
- read current owned resources; `session(id)` creates a handle without a request.
194
-
195
- Agent create/list responses contain association metadata. `getAgent`,
196
- `agent.refresh()` and `agent.update()` also include `data.additionalInstructions`.
197
- The service normalizes line endings to LF and trims whitespace, allows up to
198
- 4096 UTF-8 bytes after normalization, and rejects unsupported control characters.
199
- Null or empty text clears the setting. Updates use last committed write wins,
200
- without an ETag, and affect only future Sessions. Multiple Agents using the same
201
- Template retain independent settings and Sessions.
202
-
203
- Each Session freezes its Template recipe, digest and Agent preferences at
204
- creation. Later Template saves and Agent updates do not rewrite it. Session
205
- model selection is separate:
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:
206
299
 
207
300
  ```ts
208
301
  const current = await customer.getSession(session.id);
@@ -220,39 +313,96 @@ decisions. `session.delete()` updates and returns the Session handle; `.data` co
220
313
  and lists hide the Session. Active execution or a read-only Session can refuse
221
314
  deletion. Name and Billing Tag changes do not change the frozen recipe.
222
315
 
223
- Organization authority can explicitly unlink an Agent belonging to a target User:
224
-
225
- ```ts
226
- const archived = await organization.unlinkAgent(endUser.userId, agent.id);
227
- ```
228
-
229
- The returned association is archived. Unlinking retains existing Session
230
- ownership, binding, configuration and history; it does not grant the Organization
231
- access to existing Sessions by itself. Authorized Organization owner-scoped
232
- clients and the User retain access to saved history under the service rules.
233
-
234
316
  The packaged example completes discovery and creation without message execution:
235
317
 
236
318
  ```js
237
319
  import { createSessionWorkflow } from "@tidblink/server/examples/create-session.mjs";
238
320
 
239
- const { agent, session } = await createSessionWorkflow({
321
+ const { session } = await createSessionWorkflow({
240
322
  baseUrl: process.env.AGENT_SERVICE_URL,
241
- userApiKey: process.env.USER_API_KEY,
242
- agentCreationKey: process.env.AGENT_CREATION_KEY,
323
+ organizationApiKey: process.env.ORGANIZATION_API_KEY,
324
+ userId: process.env.END_USER_ID,
243
325
  sessionCreationKey: process.env.SESSION_CREATION_KEY,
244
- agentTemplateId: process.env.TEMPLATE_ID,
326
+ templateId: process.env.TEMPLATE_ID,
245
327
  model: process.env.MODEL_ID,
246
328
  });
247
329
  ```
248
330
 
249
- For direct execution, set `AGENT_SERVICE_URL`, `USER_API_KEY`,
250
- `TEMPLATE_ID`, `AGENT_CREATION_KEY` and `SESSION_CREATION_KEY`; `MODEL_ID` is optional.
251
- Select and persist the Template ID, optional model and both keys before calling.
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.
252
336
  The example also returns the current Template page and model catalog. If no model
253
337
  was requested, it preserves omission so the service can recover the original
254
338
  creation even after the deployment default changes.
255
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
+
256
406
  ## Text messages and original results
257
407
 
258
408
  Persist the Session ID, command identity and complete input before dispatch.
@@ -324,7 +474,8 @@ The packaged example uses an existing owned Session and a caller-retained input:
324
474
  import { sendMessageWorkflow } from "@tidblink/server/examples/send-message.mjs";
325
475
  const result = await sendMessageWorkflow({
326
476
  baseUrl: process.env.AGENT_SERVICE_URL,
327
- userApiKey: process.env.USER_API_KEY,
477
+ organizationApiKey: process.env.ORGANIZATION_API_KEY,
478
+ userId: process.env.END_USER_ID,
328
479
  sessionId: process.env.SESSION_ID,
329
480
  clientEventId: process.env.CLIENT_EVENT_ID,
330
481
  text: process.env.MESSAGE_TEXT,
@@ -337,71 +488,237 @@ final answer. Preserve the input across invocations; each new command identity
337
488
  can start new work. This slice replaces the obsolete response-owned NDJSON
338
489
  `streamTurn` API. Use the independent progress subscription below.
339
490
 
340
- ## Continuous progress and reconnection
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
341
508
 
342
- Submit once with a persisted `clientEventId`, then observe that original receipt:
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.
343
515
 
344
516
  ```ts
345
- const savedCommandId = crypto.randomUUID();
346
- // Persist savedCommandId with this message before dispatch.
347
- const accepted = await session.submitMessage({
348
- clientEventId: savedCommandId,
349
- text: "Summarize the findings",
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
350
524
  });
351
- let cursor: string | undefined;
352
- let draft = "";
353
- for await (const update of session.observe(accepted.inputId, {
354
- timeoutMs: 60_000,
355
- })) {
356
- if (update.type === "result") {
357
- console.log(update.result.text);
358
- } else {
359
- cursor = update.event.cursor; // Persist with session.id and accepted.inputId.
360
- if (update.type === "draft") draft = update.text; // Replace, including an empty reset.
361
- if (update.type === "progress") console.log(update.text);
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");
362
698
  }
363
699
  }
364
- ```
365
700
 
366
- `draft` is provisional, and a progress update or saved-message event is not a
367
- successful result. Only `type: "result"` establishes the original command's
368
- successful outcome using its durable receipt and matching saved Turn. Failures,
369
- interruption and unincorporated inputs use the same errors as `waitForResult()`.
370
- A later explicit retry cannot replace the earlier command's result. `observe()`
371
- also accepts the separate retry command identity, using the same original-result
372
- checks as `waitForResult()`; cancellation receipts are not answer receipts.
373
-
374
- `ObservationStoppedError` retains `sessionId`, `inputId`, `turnId` when known,
375
- the last `cursor`, and `timedOut`. On disconnect, local abort or timeout, preserve
376
- those values. Reconnect with `session.observe(inputId, { cursor })`, or query
377
- `getInteraction(inputId)` / `waitForResult(inputId)`; do not submit another message.
378
- Breaking the loop stops only observation. EOF, a revoked observer credential,
379
- Session idleness and the absence of subscribers do not establish success or cancel
380
- execution. With revoked credentials, use another currently authorized credential
381
- for recovery. The SDK does not automatically reconnect or resubmit.
382
-
383
- `session.events({ cursor, signal })` exposes the independent Session-wide stream
384
- as raw envelopes with `cursor`, `type`, `sessionId`, `turnId`, `createdAt` and
385
- `data`, including Realtime and unfamiliar event types. It ends when its connection
386
- ends; it has no execution-result promise. `observe()` selects the original Turn,
387
- exposes typed draft/progress text and raw other events, and confirms its original
388
- receipt before reporting a result. A missing/expired cursor is the service's
389
- `validation_error` refusal; it does not silently start replay from the beginning.
390
- A resumed draft is a complete snapshot, usable without earlier frames.
391
-
392
- The packaged `observe-message.mjs` example observes an existing input and never
393
- submits it again. Run it with `AGENT_SERVICE_URL`, `USER_API_KEY`, `SESSION_ID` and
394
- `INPUT_ID`; supply `EVENT_CURSOR` to resume:
701
+ on_button_press() { mqtt_publish("lamp/{id}/talk", ""); }
395
702
 
396
- ```sh
397
- node node_modules/@tidblink/server/examples/observe-message.mjs
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
+ }
398
707
  ```
399
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
+
400
717
  ## Session Client Tools
401
718
 
402
- Declare the application functions that an ordinary text Turn may request. The
403
- application executes them itself; the SDK only declares tools and exchanges calls
404
- and results. Parameters are JSON Schema objects. The service validates reserved
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
405
722
  names, schema size/depth and combined tool limits; the `async` argument is reserved
406
723
  for the platform and is not part of application arguments.
407
724
 
@@ -418,7 +735,8 @@ const tools: ClientToolDeclaration[] = [{
418
735
  additionalProperties: false,
419
736
  },
420
737
  }];
421
- const session = await agent.createSession({
738
+ const session = await customer.createSession({
739
+ templateId: savedTemplateId,
422
740
  idempotencyKey: savedSessionCreationKey,
423
741
  tools,
424
742
  });
@@ -429,54 +747,12 @@ await session.replaceClientTools(tools, { ifMatch: current.etag });
429
747
  Replacement is the complete set, with `[]` clearing it. Its ETag is independent of
430
748
  the Session's model ETag. Stale replacement returns `precondition_failed`; read the
431
749
  set and decide again. A Turn retains the declarations admitted when it started.
432
- An identical replacement keeps its revision. Scheduler and audio inputs do not
433
- use the Session-client declarations under this service baseline.
750
+ An identical replacement keeps its revision. Text, voice and prerecorded-audio Turns
751
+ use the Session's declarations; Scheduler-started Turns do not.
434
752
 
435
- After submitting a message, retrieve pending calls independently of any progress
436
- subscription:
437
-
438
- ```ts
439
- const calls = await session.listPendingClientToolCalls();
440
- const call = calls.find((call) => call.callId === savedCallId);
441
- if (!call || Date.parse(call.deadlineAt) <= Date.now()) {
442
- throw new Error("The call is unavailable or expired; do not execute it again");
443
- }
444
- // The application obtains this value from its own business function.
445
- const output = JSON.stringify({ status: "shipped" });
446
- const receipt = await session.submitClientToolOutput({
447
- clientEventId: savedOutputCommandId,
448
- turnId: call.turnId,
449
- callId: call.callId,
450
- output,
451
- });
452
- // To report a business failure, supply error: { message: "Warehouse unavailable" }
453
- // instead of output.
454
- ```
455
-
456
- The result command has its own stable `clientEventId`; it is distinct from the
457
- call's `callId` and the message's input identity. Its receipt confirms admission,
458
- not successful completion of the message. Continue with the original message's
459
- `waitForResult(inputId)` or `observe(inputId)`. An application-reported tool error
460
- may still produce a successful answer explaining that failure.
461
-
462
- Pending reads count as delivery and preserve `publishedAt`/`deadlineAt`. Calls
463
- have a 60-second deadline from publication. Retain the call identity and the
464
- application's result before submission; a disconnect does not authorize executing
465
- the business function again. After uncertain submission, reconcile its original
466
- command with `getInteraction(clientEventId)` or `reconcileInteraction(clientEventId)`.
467
- The SDK never retries output submission automatically. Repeating the exact command
468
- recovers its original receipt; a new command for a settled call is refused with
469
- `client_tool_call_settled`. Foreign, unknown and Hardware calls are unavailable
470
- through this endpoint. Hardware results belong to the device connection and are
471
- not an SDK Client Tool capability.
472
-
473
- The packaged `answer-client-tool.mjs` example submits one already produced result.
474
- Set `AGENT_SERVICE_URL`, `USER_API_KEY`, `SESSION_ID`, `TURN_ID`, `CALL_ID`,
475
- `CLIENT_EVENT_ID` and exactly one of `TOOL_OUTPUT` or `TOOL_ERROR`:
476
-
477
- ```sh
478
- node node_modules/@tidblink/server/examples/answer-client-tool.mjs
479
- ```
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.
480
756
 
481
757
  ## Saved conversation history
482
758
 
@@ -556,7 +832,7 @@ request, not network recovery.
556
832
  The installed `@tidblink/server/examples/control-turn.mjs` exports
557
833
  `controlTurnWorkflow`. It returns cancellation acceptance for `action: "cancel"`
558
834
  and waits for the separate retry result for `action: "retry"`. Run it with
559
- `AGENT_SERVICE_URL`, `USER_API_KEY`, `SESSION_ID`, `TURN_ID`, `CLIENT_EVENT_ID` and
835
+ `AGENT_SERVICE_URL`, `ORGANIZATION_API_KEY`, `END_USER_ID`, `SESSION_ID`, `TURN_ID`, `CLIENT_EVENT_ID` and
560
836
  `TURN_ACTION` set; retain those identities before dispatch. This example never
561
837
  chooses to retry automatically.
562
838
 
@@ -567,12 +843,12 @@ owned Session for a terminal. The SDK returns the one-time connect token but doe
567
843
  not open or implement the WebSocket transport.
568
844
 
569
845
  ```ts
570
- import { UserClient } from "@tidblink/server";
846
+ import { OrganizationClient } from "@tidblink/server";
571
847
 
572
- const user = new UserClient({
848
+ const user = new OrganizationClient({
573
849
  baseUrl: process.env.AGENT_SERVICE_URL!,
574
- apiKey: process.env.USER_API_KEY!,
575
- });
850
+ apiKey: process.env.ORGANIZATION_API_KEY!,
851
+ }).user(process.env.END_USER_ID!);
576
852
  const catalog = await user.listRealtimeModels();
577
853
  const session = user.session(process.env.SESSION_ID!);
578
854
  const current = await session.getRealtimeModelSelection();
@@ -608,13 +884,14 @@ import { prepareRealtimeConnection } from "@tidblink/server/examples/prepare-rea
608
884
 
609
885
  const prepared = await prepareRealtimeConnection({
610
886
  baseUrl: process.env.AGENT_SERVICE_URL,
611
- userApiKey: process.env.USER_API_KEY,
887
+ organizationApiKey: process.env.ORGANIZATION_API_KEY,
888
+ userId: process.env.END_USER_ID,
612
889
  sessionId: process.env.SESSION_ID,
613
890
  realtimeModelId: process.env.REALTIME_MODEL_ID,
614
891
  });
615
892
  ```
616
893
 
617
- Set `AGENT_SERVICE_URL`, `USER_API_KEY`, and `SESSION_ID` to run the example
894
+ Set `AGENT_SERVICE_URL`, `ORGANIZATION_API_KEY`, `END_USER_ID`, and `SESSION_ID` to run the example
618
895
  directly. `REALTIME_MODEL_ID` is optional.
619
896
 
620
897
  ## Prerecorded audio
@@ -660,7 +937,7 @@ does not resubmit a recording automatically. A transcription refusal may still
660
937
  have consumed provider work; it does not imply a refunded charge.
661
938
 
662
939
  The installed `submit-audio.mjs` example reads an existing file and prints the
663
- final answer. Set `AGENT_SERVICE_URL`, `USER_API_KEY`, `SESSION_ID` and
940
+ final answer. Set `AGENT_SERVICE_URL`, `ORGANIZATION_API_KEY`, `END_USER_ID`, `SESSION_ID` and
664
941
  `AUDIO_FILE_PATH`; `AUDIO_CONTENT_TYPE` is optional:
665
942
 
666
943
  ```sh
@@ -731,7 +1008,7 @@ API Key. Files are held in memory while hashing or downloading, so size your
731
1008
  backend's concurrency for the service's advertised maximum.
732
1009
 
733
1010
  The packaged `upload-file.mjs` example uploads and attaches one file. Set
734
- `AGENT_SERVICE_URL`, `USER_API_KEY`, `SESSION_ID`, `FILE_PATH`,
1011
+ `AGENT_SERVICE_URL`, `ORGANIZATION_API_KEY`, `END_USER_ID`, `SESSION_ID`, `FILE_PATH`,
735
1012
  `UPLOAD_IDEMPOTENCY_KEY`, and `CLIENT_EVENT_ID`, then run:
736
1013
 
737
1014
  ```sh
@@ -743,110 +1020,28 @@ The example returns the final conversation result; its exported
743
1020
  directories or write Session text files. Organization owner-scoped clients can
744
1021
  upload, read and attach the same ordinary Files.
745
1022
 
746
- ## Scheduled work
747
-
748
- `user.schedulers` uses the full customer Scheduler API. A new-Session schedule
749
- requires an owned Agent; a bound-Session schedule supplies only its Session.
750
- The service resolves the Agent from that Session.
751
-
752
- ```ts
753
- const created = await customer.schedulers.create({
754
- agentId: agent.id,
755
- title: "Morning report",
756
- prompt: "Summarize the latest activity.",
757
- schedule: { kind: "cron", cronExpr: "0 9 * * *", timezone: "Asia/Shanghai" },
758
- idempotencyKey: savedSchedulerKey,
759
- });
760
- const current = await customer.schedulers.get(created.scheduler.schedulerId);
761
- const updated = await customer.schedulers.update(
762
- current.scheduler.schedulerId,
763
- { enabled: false },
764
- { ifMatch: current.etag },
765
- );
766
- const history = await customer.schedulers.fires(current.scheduler.schedulerId, {
767
- limit: 20,
768
- });
769
- ```
770
-
771
- For one-time work use exactly one of `{ kind: "once", runAt: isoTimestamp }` or
772
- `{ kind: "once", after: "1h30m" }`. `runAt` includes a timezone offset; cron
773
- requires an IANA timezone. The service owns timing and validation. Optional
774
- `validFrom` and `validUntil` restrict the execution window. A bound-Session create
775
- uses `delivery: { target: "bound_session", sessionId }` and omits `agentId`.
776
-
777
- Creation returns `{ scheduler, etag }`. Persist the key and exact input before
778
- calling. A same-key replay returns the **original creation response and ETag**,
779
- even if the Scheduler has since changed; use `get()` for current state before an
780
- edit. A changed input with that key is refused. Uncertain responses raise
781
- `OutcomeUnknownError` rather than repeating the command automatically.
782
- `idempotency_in_progress` retains the service's raw `retryAfter` header on the
783
- error so the application can decide when to replay the original request.
784
-
785
- `update(id, changes, { ifMatch })` and `delete(id, { ifMatch })` require a
786
- previously read ETag. Stale writes retain the service's 412 conflict; the SDK
787
- never refreshes and overwrites another writer's changes. Updates support title,
788
- prompt, Agent/delivery target, schedule, enabled state, validity window and
789
- `snoozeUntil`. Enabling an elapsed one-time schedule requires a future time.
790
- Disabling or deleting a Scheduler does not roll back completed effects. Deleted
791
- Schedulers are no longer available through detail or fire-history reads.
792
- `organization.user(userId).schedulers.list({ status: "deleted" })` returns their
793
- retained tombstones. User-Key lists accept only `enabled` or `disabled`; both
794
- list surfaces omit deleted records by default.
795
-
796
- `list({ status, limit, cursor })` and `fires(id, { limit, cursor })` return
797
- `nextCursor` rather than `hasMore`. Cursors retain their original owner/filter
798
- scope. Each fire preserves its execution prompt and delivery snapshot; later
799
- Scheduler edits cannot rewrite it. `pending` is not success. A `completed` fire
800
- or a `failed` fire retains nullable result identities and a nullable failure code.
801
- When `result.sessionId` and `result.turnId` are present, read the saved Turn with
802
- `customer.session(sessionId).getTurn(turnId)`; do not invent a message identity
803
- when `messageId` is null.
804
-
805
- The installed `schedule-task.mjs` example creates one scheduled task, waits
806
- locally for a settled fire and reads its associated Turn. Set `AGENT_SERVICE_URL`,
807
- `USER_API_KEY`, `AGENT_ID`, `SCHEDULER_IDEMPOTENCY_KEY`, `SCHEDULE_RUN_AT` and
808
- `SCHEDULE_PROMPT`, then run:
809
-
810
- ```sh
811
- node node_modules/@tidblink/server/examples/schedule-task.mjs
812
- ```
813
-
814
- A local example timeout leaves the schedule active and prints its identity for
815
- later fire-history reads. The separate Scheduler plans API is outside this SDK.
816
-
817
1023
  ## Errors and uncertain outcomes
818
1024
 
819
- Organization, text/progress/history and refreshed Agent/Session lifecycle methods accept
1025
+ Organization, text/progress/history and Session lifecycle methods accept
820
1026
  `{ requestId?, signal? }` options; creation and list options include these fields.
821
1027
  `TiDBLinkApiError` preserves status, service code, request identity and details;
822
1028
  `ConflictError` preserves explicit 409/412 conflicts. The service's response
823
1029
  request identity takes precedence over the one sent.
824
1030
 
825
- Safe reads and keyed Agent/Session creation use bounded retries with the same
826
- request identity and creation key. User creation, key issuance, preference and
827
- Session updates, unlinking, deletion and Realtime mutations are never
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
828
1034
  automatically replayed. Disconnects, unusable success responses, HTTP 408 and
829
1035
  server failures during these mutations raise `OutcomeUnknownError`. For an HTTP failure, its
830
- `cause` retains the `TiDBLinkApiError`. Repeating revoke is safe, but an ambiguous
831
- failure still reports an unknown outcome. `AbortSignal` stops the local request;
1036
+ `cause` retains the `TiDBLinkApiError`. `AbortSignal` stops the local request;
832
1037
  after dispatch it does not establish whether a mutation happened. Redirects are
833
1038
  refused.
834
1039
 
835
- After uncertain User creation, list and match the original `externalId`.
836
- After uncertain issuance or rotation, listing keys can confirm metadata but
837
- **cannot recover the one-time token**. Reconcile metadata and explicitly revoke
838
- any unwanted key before deciding whether to issue again. The provisioning
839
- example never repeats an uncertain credential operation.
840
-
841
- Credential receipts must match the requested User (creation) or API Key
842
- (rotation/revocation), with the expected status. Issued tokens must also match
843
- their key metadata. A mismatched or unusable success receipt raises
844
- `OutcomeUnknownError` with the request identity; it never triggers an automatic
845
- credential retry.
846
-
847
- After uncertain Agent/Session creation, repeat the original input with the same
848
- creation key. After an uncertain preference, name, tag or model update, read the
849
- resource before deciding on another change. Unlinking and deletion can hide a
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
850
1045
  resource from ordinary reads, so a missing resource does not recover its original
851
1046
  mutation receipt.
852
1047
 
@@ -857,27 +1052,22 @@ new token after reconciling the connection state.
857
1052
 
858
1053
  ## User usage
859
1054
 
860
- A User client reads its own usage. An Organization client must name a target User
861
- in that Organization. These are distinct response projections and date contracts:
1055
+ `organization.getUserUsage(userId, options)` and
1056
+ `organization.getUserUsageDetails(userId, options)` read one target User's usage.
862
1057
 
863
- | Read | User client | Organization client |
864
- | --- | --- | --- |
865
- | Summary | `getUsage(options)` | `getUserUsage(userId, options)` |
866
- | Details | `getUsageDetails(options)` | `getUserUsageDetails(userId, options)` |
867
- | Range | Optional inclusive UTC dates, `YYYY-MM-DD`; at most 370 days | Required exact UTC timestamps with milliseconds, inclusive `from` and exclusive `to`; at most 31 days |
868
- | Buckets | `period: "day"` or `"week"` chooses the default range; series remains daily | `interval: "hour"`, `"day"`, `"week"` or `"month"` |
869
- | Detail limits | Separate `llm`, `sandbox` and `webSearch` arrays; `limit` applies per kind, with no cursor | Globally ordered `items`; optional `kind`, `limit` and opaque `cursor`; `nextCursor: null` ends the stream |
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 |
870
1063
 
871
1064
  Both support an exact `tagKey`/`tagValue` pair. Send both or neither. Values retain
872
- case sensitivity and the service's normalization rules. Preserve the original
873
- range, kind and tag filters when following an Organization cursor. Details accept
874
- limits from 1 to 500. The User detail endpoint cannot retrieve older rows beyond
875
- its limit through a fabricated continuation; Organization details support paging.
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.
876
1069
 
877
1070
  ```ts
878
- const own = await user.getUsage({
879
- from: "2030-01-02", to: "2030-01-02", period: "day",
880
- });
881
1071
  const page = await organization.getUserUsageDetails(targetUserId, {
882
1072
  from: "2030-01-02T00:00:00.000Z",
883
1073
  to: "2030-01-03T00:00:00.000Z",
@@ -892,14 +1082,12 @@ an unpriced fact billable. Priced totals exclude unpriced facts. Scalar cost tot
892
1082
  costs; `costByBearer` preserves the separate platform, customer and unknown
893
1083
  categories. Public-list estimates are not supplier invoices or known final charges. Fact completeness and
894
1084
  valuation completeness are independent. Do not display missing costs as zero or
895
- infer a balance from these reads. Organization projections intentionally omit
896
- some details available in the User's own projection.
1085
+ infer a balance from these reads.
897
1086
 
898
1087
  The installed example `@tidblink/server/examples/read-usage.mjs` exports
899
- `readOwnUsage` and `readUserUsage`, each returning a summary and the first detail
900
- page. The CLI requires `AGENT_SERVICE_URL` and `USAGE_AUTHORITY=user` with
901
- `USER_API_KEY`, or `USAGE_AUTHORITY=organization` with `ORGANIZATION_API_KEY` and
902
- `END_USER_ID`. Supply `USAGE_FROM`/`USAGE_TO` in the corresponding date format;
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;
903
1091
  `TAG_KEY`/`TAG_VALUE` are optional. It performs reads only and does not claim that
904
1092
  a first detail page is complete history.
905
1093
 
@@ -922,7 +1110,7 @@ packed artifact in a clean ESM TypeScript consumer. To verify the packed SDK and
922
1110
  its examples against the real service, use the service's Node.js 26 runtime, install its
923
1111
  pinned dependencies, generate Prisma, build its Hardware contract and prepare its
924
1112
  current schema in a **dedicated test database**. See the selected service's
925
- [current-schema setup](https://github.com/mem9-ai/agent-stack/blob/882287809f91d9a3fdf60537460431d9f0fc23e7/docs/prisma-first-tidb-validation-and-rollout.md).
1113
+ [current-schema setup](https://github.com/mem9-ai/agent-stack/blob/4305ef67daebf23c416badf806217a7c11dcef37/docs/prisma-first-tidb-validation-and-rollout.md).
926
1114
 
927
1115
  ```sh
928
1116
  AGENT_SERVICE_ROOT=/path/to/agent-stack \
@@ -944,11 +1132,11 @@ The compiled fixture reuses the service system harness, its isolated database,
944
1132
  controlled model/storage/ASR/voice dependencies and real background runner. A
945
1133
  separate process installs the tarball in a clean directory and calls only the
946
1134
  installed SDK and packaged Organization example for its application workflow.
947
- It verifies Organization provisioning without a User Key, discovery, Agent/Session
1135
+ It verifies Organization provisioning, discovery, Session
948
1136
  creation replay, owner isolation, file upload/download and message attachment,
949
- text results, SSE/history, Client Tools, audio, Scheduler fires and deleted history,
950
- Realtime HTTP grants, rotation/revocation and fresh-authority recovery. A separate
951
- User-Key control reads the same retained resources. Management credential changes
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
952
1140
  and controlled suppliers are fixture actions, not claimed SDK APIs. WebSocket
953
1141
  redemption, live speech suppliers, physical hardware and deployment compatibility
954
1142
  are outside this SDK proof.
@@ -957,18 +1145,23 @@ The remaining fixtures use the service's existing AppModule test seam and reset
957
1145
  all rows in the selected dedicated test database before and after their runs.
958
1146
  They use genuine credentials, real TiDB and controlled dependencies. Their scope:
959
1147
  Fixtures prepare identities, Templates and eligible
960
- Realtime Sessions. A composed installed quickstart provisions one fresh User/key,
961
- discovers Templates/models, creates an Agent and Session, obtains a final answer
1148
+ Realtime Sessions. A composed installed quickstart provisions one fresh User,
1149
+ discovers Templates/models, creates a Session, obtains a final answer
962
1150
  and recovers the same original result. Public SDK methods also verify ownership,
963
- lifecycle updates and Realtime operations. Text cases
1151
+ lifecycle updates, Template switches with and without a Handoff Turn, and Realtime
1152
+ operations. Text cases
964
1153
  exercise actual acceptance, final success/failure/interruption, local timeout and
965
1154
  abandoned-wait recovery, response-loss reconciliation, and paginated mixed
966
- history through public SDK methods. Progress cases verify draft replacement, cursor
967
- reconnection/refusal, original-result confirmation, local observation timeout and
968
- live credential revocation without remote cancellation. Client Tool cases declare and replace tools, resume pending reads, explicitly return
969
- application output or errors, and observe those values in real Runtime answers, with
970
- ownership, stale revision, settled-call and unavailable-call refusals. A forwarding proxy drops only
971
- an actual service response to prove uncertain-submission recovery. Control cases
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
972
1165
  verify active-Turn steering, eventual cancellation, disabled retry and an explicitly
973
1166
  enabled checkpoint retry with an unchanged original outcome. The enabled fixture
974
1167
  uses the existing controlled Drive9 and model suppliers. File cases use a separate
@@ -977,16 +1170,12 @@ They verify inline, multipart and empty uploads, cancellation and invalid conten
977
1170
  large-file download-ticket redemption, and a real Runtime file read whose answer
978
1171
  contains the uploaded content. The installed upload example exercises the same
979
1172
  public workflow. Real supplier deployment certification is outside this evidence.
980
- Scheduler cases enable the real background runner and use SDK-created schedules
981
- to verify successful and failed execution, delivery changes, retained fire
982
- snapshots and the installed example.
983
- Audio cases use the real transcription adapter with a controlled HTTP supplier and
984
- real spend limits. They cover recorded input, malformed/unsupported recordings,
985
- supplier and budget refusals, model failure, active-response interruption and the
986
- installed audio example.
987
- The public Session projection exposes the frozen Template digest but hides stored
988
- Agent preferences. Preference snapshots and active-Turn model freezing also rely
989
- on the selected service's [existing persistence evidence](https://github.com/mem9-ai/agent-stack/blob/882287809f91d9a3fdf60537460431d9f0fc23e7/test/product/sessions/sessions-withorgstore.tidb.spec.ts); SDK reads alone do not
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
990
1179
  prove those hidden values.
991
1180
  Usage cases seed controlled facts and verify both public projections, exact filters,
992
1181
  paging and authority without executing messages to manufacture billing data.