@scitrera/aether-client 0.1.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,679 @@
1
+ # @scitrera/aether-client
2
+
3
+ TypeScript/JavaScript SDK for the [Aether](https://github.com/scitrera/aether) distributed control plane.
4
+
5
+ Aether is a distributed control plane for routing structured messages, tracking tasks, and managing connection lifecycles. This SDK provides TypeScript/JavaScript clients for agents, users, and other principal types.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @scitrera/aether-client
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ### Agent Client
16
+
17
+ Agents are persistent entities with workspace/implementation/specifier identity. Each agent identity can only have one active connection at a time (Connection = Lock paradigm).
18
+
19
+ ```typescript
20
+ import { AgentClient, MessageType } from "@scitrera/aether-client";
21
+
22
+ const agent = new AgentClient({
23
+ address: "localhost:50051",
24
+ workspace: "production",
25
+ implementation: "data-processor",
26
+ specifier: "instance-1",
27
+ });
28
+
29
+ // Register handlers
30
+ agent.onMessage((msg) => {
31
+ const text = new TextDecoder().decode(msg.payload);
32
+ console.log(`Received from ${msg.sourceTopic}: ${text}`);
33
+ });
34
+
35
+ agent.onConfig((config) => {
36
+ console.log("Workspace KV keys:", Object.keys(config.kv));
37
+ // values are Uint8Array; decode with msgpack/TextDecoder as needed
38
+ console.log("Global KV keys:", Object.keys(config.globalKv));
39
+ });
40
+
41
+ agent.onConnect((ack) => {
42
+ console.log(`Connected with session ${ack.sessionId} (resumed: ${ack.resumed})`);
43
+ });
44
+
45
+ agent.onDisconnect((reason) => {
46
+ console.log(`Disconnected: ${reason}`);
47
+ });
48
+
49
+ // Connect to the gateway
50
+ await agent.connect();
51
+
52
+ // Send messages
53
+ const encoder = new TextEncoder();
54
+ agent.sendToAgent("production", "other-agent", "instance-2", encoder.encode("Hello!"));
55
+ agent.sendToUser("alice", "tab-1", encoder.encode(JSON.stringify({ status: "complete" })));
56
+
57
+ // Broadcast to all agents in workspace
58
+ agent.broadcastToAgents("production", encoder.encode("announcement"));
59
+
60
+ // Disconnect when done
61
+ await agent.disconnect();
62
+ ```
63
+
64
+ ### User Client
65
+
66
+ Users are identified by userId and windowId, allowing multiple browser tabs per user. Users can only send direct messages (no events or metrics).
67
+
68
+ ```typescript
69
+ import { UserClient } from "@scitrera/aether-client";
70
+
71
+ const user = new UserClient({
72
+ address: "localhost:50051",
73
+ userId: "alice",
74
+ windowId: "tab-1",
75
+ workspace: "production",
76
+ });
77
+
78
+ user.onIncomingMessage((msg) => {
79
+ const text = new TextDecoder().decode(msg.payload);
80
+ console.log(`Message from ${msg.sourceTopic}: ${text}`);
81
+ });
82
+
83
+ await user.connect();
84
+
85
+ // Send a message to an agent
86
+ const encoder = new TextEncoder();
87
+ user.sendToAgent(
88
+ "production",
89
+ "data-processor",
90
+ "instance-1",
91
+ encoder.encode(JSON.stringify({ action: "process", data: [1, 2, 3] })),
92
+ );
93
+ ```
94
+
95
+ ### TaskClient
96
+
97
+ Tasks can be unique (named, persistent identity like agents) or non-unique (server-assigned ID, load-balanced):
98
+
99
+ ```typescript
100
+ import { TaskClient } from "@scitrera/aether-client";
101
+
102
+ // Unique task — persistent identity, only one active connection
103
+ const uniqueTask = new TaskClient({
104
+ address: "localhost:50051",
105
+ workspace: "prod",
106
+ implementation: "report-gen",
107
+ uniqueSpecifier: "daily-report",
108
+ });
109
+
110
+ // Non-unique task — server-assigned ID, multiple instances allowed
111
+ const worker = new TaskClient({
112
+ address: "localhost:50051",
113
+ workspace: "prod",
114
+ implementation: "data-processor",
115
+ // no uniqueSpecifier — becomes a pool worker
116
+ });
117
+
118
+ worker.onMessage((msg) => {
119
+ console.log(`Task received from ${msg.sourceTopic}:`, msg.payload);
120
+ });
121
+
122
+ await worker.connect();
123
+
124
+ // Send events and metrics
125
+ const encoder = new TextEncoder();
126
+ worker.sendEvent(encoder.encode(JSON.stringify({ type: "task.started" })));
127
+ worker.sendMetric(encoder.encode(JSON.stringify({ cpu: 0.4 })));
128
+
129
+ // Report progress
130
+ worker.reportProgress({
131
+ taskId: "task-123",
132
+ state: "running",
133
+ completion: 0.5,
134
+ summary: "Processing batch 50/100",
135
+ });
136
+ ```
137
+
138
+ ## Checkpoint API
139
+
140
+ Agents and tasks can persist state using the checkpoint store:
141
+
142
+ ```typescript
143
+ const cp = agent.checkpoint();
144
+
145
+ // Save state
146
+ await cp.saveSync({
147
+ key: "my-state",
148
+ data: encoder.encode(JSON.stringify({ step: 5, results: [1, 2, 3] })),
149
+ ttl: 3600, // seconds, 0 = no expiration
150
+ });
151
+
152
+ // Load state
153
+ const response = await cp.loadSync({ key: "my-state" });
154
+ if (response.success) {
155
+ const state = JSON.parse(new TextDecoder().decode(response.data));
156
+ console.log("Restored state:", state);
157
+ }
158
+
159
+ // List checkpoint keys
160
+ const listResp = await cp.listSync({});
161
+ console.log("Checkpoint keys:", listResp.keys);
162
+
163
+ // Delete a checkpoint
164
+ await cp.deleteSync({ key: "my-state" });
165
+ ```
166
+
167
+ ## Task Management API
168
+
169
+ Any connected client can query and manage tasks:
170
+
171
+ ```typescript
172
+ // List tasks with optional filters
173
+ const listResp = await agent.queryTasks({
174
+ workspace: "prod",
175
+ status: "running",
176
+ taskType: "data-processor",
177
+ limit: 50,
178
+ offset: 0,
179
+ timeout: 10000, // ms
180
+ });
181
+ console.log(`Found ${listResp.totalCount} tasks`);
182
+
183
+ // Get a specific task
184
+ const getResp = await agent.getTask("task-abc123");
185
+ if (getResp.task) {
186
+ console.log("Task status:", getResp.task.status);
187
+ }
188
+
189
+ // Cancel a task
190
+ await agent.cancelTask("task-abc123", "user requested cancellation");
191
+
192
+ // Retry a failed task
193
+ await agent.retryTask("task-abc123");
194
+
195
+ // Mark as complete (for pool workers)
196
+ await agent.completeTask("task-abc123");
197
+
198
+ // Mark as failed (for pool workers)
199
+ await agent.failTask("task-abc123", "processing error");
200
+ ```
201
+
202
+ ## Authentication
203
+
204
+ ```typescript
205
+ import { AgentClient, withAPIKey, withToken, withTenant } from "@scitrera/aether-client";
206
+
207
+ // API Key authentication
208
+ const agent = new AgentClient({
209
+ address: "gateway.example.com:50051",
210
+ workspace: "production",
211
+ implementation: "worker",
212
+ specifier: "1",
213
+ credentials: {
214
+ ...withAPIKey("your-api-key"),
215
+ ...withTenant("your-tenant-id"),
216
+ },
217
+ });
218
+
219
+ // OAuth/JWT authentication
220
+ const agent2 = new AgentClient({
221
+ address: "gateway.example.com:50051",
222
+ workspace: "production",
223
+ implementation: "worker",
224
+ specifier: "2",
225
+ credentials: withToken("your-jwt-token"),
226
+ });
227
+ ```
228
+
229
+ ## TLS Configuration
230
+
231
+ ```typescript
232
+ import { AgentClient } from "@scitrera/aether-client";
233
+ import { readFileSync } from "fs";
234
+
235
+ const agent = new AgentClient({
236
+ address: "gateway.example.com:50051",
237
+ workspace: "production",
238
+ implementation: "worker",
239
+ specifier: "1",
240
+ tls: {
241
+ rootCerts: readFileSync("ca.pem"),
242
+ // For mTLS:
243
+ privateKey: readFileSync("client-key.pem"),
244
+ certChain: readFileSync("client-cert.pem"),
245
+ },
246
+ });
247
+ ```
248
+
249
+ ## KV Store Operations
250
+
251
+ Access the hierarchical key-value store through any client:
252
+
253
+ ```typescript
254
+ import { KVScope } from "@scitrera/aether-client";
255
+
256
+ const kv = agent.kv();
257
+
258
+ // Async operations (fire-and-forget, responses via onKVResponse callback)
259
+ kv.putGlobal("my-key", encoder.encode("my-value"));
260
+ kv.getGlobal("my-key");
261
+
262
+ // Sync operations (Promise-based with timeout)
263
+ const response = await kv.getSync({
264
+ key: "my-key",
265
+ scope: KVScope.Global,
266
+ timeout: 5000, // ms
267
+ });
268
+
269
+ if (response.success) {
270
+ console.log("Value:", response.value);
271
+ }
272
+
273
+ // Workspace-scoped operations
274
+ await kv.putSync({
275
+ key: "config",
276
+ value: encoder.encode(JSON.stringify({ debug: true })),
277
+ scope: KVScope.Workspace,
278
+ workspace: "production",
279
+ ttl: 3600, // seconds
280
+ });
281
+ ```
282
+
283
+ ## Progress Reporting
284
+
285
+ Agents and tasks report progress through the `pg.{workspace}` stream. Users subscribed to the workspace receive filtered updates:
286
+
287
+ ```typescript
288
+ agent.reportProgress({
289
+ taskId: "task-123",
290
+ state: "running", // e.g. "running", "finishing", "idle"
291
+ completion: 0.5, // 0.0–1.0, or -1 for indeterminate
292
+ summary: "Processing batch 50/100",
293
+ // Optional step info for multi-step operations:
294
+ stepName: "Data validation",
295
+ stepDetail: "Checking schema for 1000 records",
296
+ stepSequence: 2,
297
+ stepTotal: 4,
298
+ stepType: "validation",
299
+ // Optional targeting:
300
+ recipient: "us.alice.tab-1", // empty = broadcast to all workspace users
301
+ requestId: "req-abc",
302
+ metadata: { batchId: "b-99" },
303
+ });
304
+ ```
305
+
306
+ ## AdminClient
307
+
308
+ `AdminClient` wraps any connected `AetherClient` and exposes named methods for gateway administration: tokens, ACL rules, workspaces, agents, and connection management.
309
+
310
+ ```typescript
311
+ import { AgentClient, AdminClient, withAPIKey } from "@scitrera/aether-client";
312
+
313
+ const agent = new AgentClient({
314
+ address: "localhost:50051",
315
+ workspace: "default",
316
+ implementation: "admin-agent",
317
+ specifier: "ops-1",
318
+ credentials: withAPIKey("admin-api-key"),
319
+ });
320
+ await agent.connect();
321
+
322
+ const admin = new AdminClient(agent);
323
+
324
+ // --- Token management ---
325
+ const { plaintextToken } = await admin.createToken({
326
+ name: "ci-token",
327
+ principalType: "agent",
328
+ workspacePatterns: ["production", "staging"],
329
+ scopes: ["read", "write"],
330
+ expiresInSeconds: 86400,
331
+ });
332
+ console.log("Token:", plaintextToken);
333
+
334
+ await admin.revokeToken({ tokenId: "tok-123" });
335
+ const { tokens } = await admin.listTokens({ principalType: "agent" });
336
+
337
+ // --- ACL rules ---
338
+ await admin.createACLRule({
339
+ principalType: "user",
340
+ principalId: "alice",
341
+ resourceType: "workspace",
342
+ resourceId: "production",
343
+ permission: "write",
344
+ });
345
+
346
+ await admin.deleteACLRule({ ruleId: "rule-456" });
347
+ const aclResp = await admin.listACLRules({ principalType: "user" });
348
+
349
+ // --- Workspace management ---
350
+ await admin.createWorkspace({ workspaceId: "staging", displayName: "Staging" });
351
+ await admin.updateWorkspace({ workspaceId: "staging", displayName: "Staging Env" });
352
+ const wsr = await admin.listWorkspaces({ limit: 50 });
353
+ await admin.deleteWorkspace({ workspaceId: "old-workspace" });
354
+
355
+ // --- Agent registry ---
356
+ const agentsResp = await admin.listAgents({ workspace: "production" });
357
+ const agentInfo = await admin.getAgent({ implementation: "data-processor" });
358
+
359
+ // --- Connection management ---
360
+ const health = await admin.getHealth();
361
+ const conns = await admin.getConnections({ workspace: "production" });
362
+ await admin.disconnectSession({ sessionId: "sess-789", reason: "maintenance" });
363
+ ```
364
+
365
+ ## Auto-Reconnection
366
+
367
+ All clients support automatic reconnection with exponential backoff:
368
+
369
+ ```typescript
370
+ const agent = new AgentClient({
371
+ address: "localhost:50051",
372
+ workspace: "production",
373
+ implementation: "worker",
374
+ specifier: "1",
375
+ reconnect: true, // default: true
376
+ reconnectDelay: 1000, // initial delay in ms (default: 1000)
377
+ maxReconnectDelay: 30000, // max delay in ms (default: 30000)
378
+ connection: {
379
+ maxRetries: 10, // 0 = infinite (default: 5)
380
+ backoffMultiplier: 2.0, // default: 2.0
381
+ },
382
+ });
383
+
384
+ agent.onReconnecting((attempt) => {
385
+ console.log(`Reconnection attempt ${attempt}...`);
386
+ });
387
+ ```
388
+
389
+ ## Retry on Duplicate Identity
390
+
391
+ When a previous instance crashes and reconnects before the distributed lock expires, the gateway returns `ALREADY_EXISTS`. Enable `retryOnDuplicate` to wait and retry automatically:
392
+
393
+ ```typescript
394
+ const agent = new AgentClient({
395
+ address: "localhost:50051",
396
+ workspace: "production",
397
+ implementation: "worker",
398
+ specifier: "1",
399
+ retryOnDuplicate: true, // retry on ALREADY_EXISTS (default: false)
400
+ retryOnDuplicateDelay: 5000, // wait 5 s between retries (default: 5000)
401
+ connection: {
402
+ retryOnDuplicateMaxAttempts: 5, // give up after 5 attempts (default: 5)
403
+ },
404
+ });
405
+ ```
406
+
407
+ ## Error Handling
408
+
409
+ The SDK provides a structured error hierarchy:
410
+
411
+ ```typescript
412
+ import {
413
+ AetherError,
414
+ ConnectionError,
415
+ AuthenticationError,
416
+ DuplicateIdentityError,
417
+ TimeoutError,
418
+ isRecoverable,
419
+ isConnectionError,
420
+ } from "@scitrera/aether-client";
421
+
422
+ try {
423
+ await agent.connect();
424
+ } catch (err) {
425
+ if (err instanceof AuthenticationError) {
426
+ console.error("Authentication failed:", err.message);
427
+ } else if (err instanceof DuplicateIdentityError) {
428
+ console.error("Identity already in use:", err.identity);
429
+ } else if (err instanceof ConnectionError) {
430
+ console.error("Connection failed:", err.message);
431
+ }
432
+
433
+ // Or use classification helpers
434
+ if (!isRecoverable(err as Error)) {
435
+ console.error("Non-recoverable error, will not retry");
436
+ }
437
+ }
438
+ ```
439
+
440
+ ## Topic Schema
441
+
442
+ The SDK provides helpers for constructing topic strings:
443
+
444
+ ```typescript
445
+ import {
446
+ agentTopic,
447
+ userTopic,
448
+ uniqueTaskTopic,
449
+ taskBroadcastTopic,
450
+ globalAgentsTopic,
451
+ eventTopic,
452
+ bridgeTopic,
453
+ } from "@scitrera/aether-client";
454
+
455
+ agentTopic("prod", "worker", "inst-1"); // "ag.prod.worker.inst-1"
456
+ userTopic("alice", "tab-1"); // "us.alice.tab-1"
457
+ uniqueTaskTopic("prod", "report", "daily"); // "tu.prod.report.daily"
458
+ taskBroadcastTopic("prod", "worker"); // "tb.prod.worker"
459
+ globalAgentsTopic("prod"); // "ga.prod"
460
+ eventTopic("task.completed"); // "event.task.completed"
461
+ bridgeTopic("aether-msgbridge", "discord-1"); // "br.aether-msgbridge.discord-1"
462
+ ```
463
+
464
+ ## Principal Types
465
+
466
+ Aether supports 8 principal types. The TypeScript SDK provides dedicated client classes for all of them except `Service`:
467
+
468
+ | Type | Client Class | Description | Topic Format |
469
+ |------|-------------|-------------|-------------|
470
+ | Agent | `AgentClient` | Persistent entity | `ag.{workspace}.{impl}.{spec}` |
471
+ | UniqueTask | `TaskClient` (with specifier) | Named task | `tu.{workspace}.{impl}.{spec}` |
472
+ | NonUniqueTask | `TaskClient` (no specifier) | Ephemeral task | `ta.{workspace}.{impl}.{id}` |
473
+ | User | `UserClient` | Browser session | `us.{userId}.{windowId}` |
474
+ | Orchestrator | `OrchestratorClient` | Compute provisioner | receives `TaskAssignment` |
475
+ | WorkflowEngine | `WorkflowEngineClient` | Event processor (singleton) | subscribes to `event.*` |
476
+ | MetricsBridge | `MetricsBridgeClient` | Telemetry collector (singleton) | subscribes to `metric.*` |
477
+ | Bridge | `BridgeClient` | Cross-workspace relay | `br.{impl}.{spec}` |
478
+ | Service | _(no dedicated client)_ | Sidecar service proxy | `sv.{impl}.{spec}` |
479
+
480
+ > **Known gap:** The TypeScript SDK does not currently have a dedicated `ServiceClient`. The `Service` principal type represents sidecar services addressable via the HTTP proxy feature. If you need to connect as a service principal, use `BridgeClient` (cross-workspace) or `AgentClient` (workspace-scoped) as a workaround and set your identity fields to match the service's `impl`/`spec`. A dedicated `ServiceClient` is planned for a future release.
481
+
482
+ ### OrchestratorClient
483
+
484
+ Orchestrators receive task assignments when targeted agents are offline and launch compute resources:
485
+
486
+ ```typescript
487
+ import { OrchestratorClient, BaseOrchestrator } from "@scitrera/aether-client";
488
+ import type { TaskAssignment } from "@scitrera/aether-client";
489
+
490
+ // Low-level: OrchestratorClient
491
+ const orch = new OrchestratorClient({
492
+ address: "localhost:50051",
493
+ implementation: "k8s-orchestrator",
494
+ supportedProfiles: ["kubernetes", "docker"],
495
+ specifier: "instance-1", // optional, auto-generated if omitted
496
+ });
497
+
498
+ orch.onTaskAssignment((assignment) => {
499
+ console.log(`Launch ${assignment.targetImplementation} for task ${assignment.taskId}`);
500
+ console.log("Profile:", assignment.profile);
501
+ console.log("Params:", assignment.launchParams);
502
+ });
503
+
504
+ await orch.connect();
505
+
506
+ // High-level: extend BaseOrchestrator
507
+ class MyOrchestrator extends BaseOrchestrator {
508
+ async launchTask(assignment: TaskAssignment): Promise<void> {
509
+ // Start a container, subprocess, etc.
510
+ console.log(`Starting ${assignment.targetImplementation}`);
511
+ }
512
+ }
513
+
514
+ const myOrch = new MyOrchestrator({
515
+ address: "localhost:50051",
516
+ implementation: "my-orchestrator",
517
+ supportedProfiles: ["my-profile"],
518
+ logAssignments: true,
519
+ });
520
+ await myOrch.connect();
521
+ ```
522
+
523
+ ### WorkflowEngineClient
524
+
525
+ The workflow engine receives all events and can send commands to any principal:
526
+
527
+ ```typescript
528
+ import { WorkflowEngineClient } from "@scitrera/aether-client";
529
+
530
+ const engine = new WorkflowEngineClient({
531
+ address: "localhost:50051",
532
+ });
533
+
534
+ engine.onMessage((msg) => {
535
+ const event = JSON.parse(new TextDecoder().decode(msg.payload));
536
+ console.log(`Event from ${msg.sourceTopic}:`, event);
537
+
538
+ // React to event: send commands to agents
539
+ const encoder = new TextEncoder();
540
+ engine.sendCommandToAgent("prod", "processor", "inst-1",
541
+ encoder.encode(JSON.stringify({ action: "process", eventId: event.id })),
542
+ );
543
+ });
544
+
545
+ await engine.connect();
546
+ ```
547
+
548
+ ### MetricsBridgeClient
549
+
550
+ The metrics bridge is receive-only — it subscribes to `metric.*` topics:
551
+
552
+ ```typescript
553
+ import { MetricsBridgeClient } from "@scitrera/aether-client";
554
+
555
+ const bridge = new MetricsBridgeClient({
556
+ address: "localhost:50051",
557
+ });
558
+
559
+ bridge.onMessage((msg) => {
560
+ const metric = JSON.parse(new TextDecoder().decode(msg.payload));
561
+ console.log(`Metric from ${msg.sourceTopic}:`, metric);
562
+ // Forward to Prometheus, Datadog, etc.
563
+ });
564
+
565
+ await bridge.connect();
566
+ ```
567
+
568
+ ### BridgeClient
569
+
570
+ Bridges operate cross-workspace and can send to any topic in any workspace:
571
+
572
+ ```typescript
573
+ import { BridgeClient, MessageType } from "@scitrera/aether-client";
574
+
575
+ const bridge = new BridgeClient({
576
+ address: "localhost:50051",
577
+ implementation: "aether-msgbridge",
578
+ specifier: "discord-1",
579
+ });
580
+
581
+ bridge.onMessage((msg) => {
582
+ // Receive messages addressed to this bridge
583
+ console.log(`Received from ${msg.sourceTopic}:`, msg.payload);
584
+ });
585
+
586
+ await bridge.connect();
587
+
588
+ // Send to any workspace — bridges are cross-workspace by design
589
+ const encoder = new TextEncoder();
590
+ bridge.sendToAgent("prod", "my-agent", "instance-1",
591
+ encoder.encode(JSON.stringify({ from: "discord", text: "Hello!" })),
592
+ );
593
+ bridge.sendToUser("alice", "tab-1",
594
+ encoder.encode("Notification from Discord"),
595
+ );
596
+ bridge.broadcastToUsers("prod",
597
+ encoder.encode("System announcement"),
598
+ MessageType.Control,
599
+ );
600
+ ```
601
+
602
+ ## Proxy
603
+
604
+ Route HTTP requests through the Aether connection to a service principal using
605
+ `AetherFetchTransport`, which provides a Fetch-compatible interface:
606
+
607
+ ```typescript
608
+ import { AetherFetchTransport } from "@scitrera/aether-client/proxy";
609
+
610
+ const transport = new AetherFetchTransport(agentClient, "sv::memorylayer::default");
611
+ const resp = await transport.fetch("/v1/memories/abc");
612
+ ```
613
+
614
+ `AetherFetchTransport.fetch()` accepts the same signature as the Web Fetch API
615
+ (`string | URL | Request`, optional `RequestInit`). The URL hostname and
616
+ protocol are ignored — only the path and query string are forwarded.
617
+
618
+ For full details on sidecar deployment, service addressing, ACL/OBO model,
619
+ limits, audit events, and failure modes, see
620
+ [server/docs/proxy.md](../../server/docs/proxy.md).
621
+
622
+ ## Foreign Audit Logging
623
+
624
+ Any connected principal can submit structured audit events directly to the gateway's audit pipeline using `submitAuditEvent`. This is useful for recording application-level actions (e.g. completed workflow steps, tool invocations, or policy decisions) alongside infrastructure events already captured by the gateway. The gateway accepts the event into its async audit queue and responds synchronously; `success: false` indicates the event was rejected (e.g. due to an ACL restriction or rate limit) but does not throw.
625
+
626
+ ```typescript
627
+ await client.submitAuditEvent({
628
+ eventType: "message",
629
+ operation: "completed_workflow_step",
630
+ metadata: { workflowId: "abc-123" },
631
+ });
632
+ ```
633
+
634
+ ## Workspace Switching
635
+
636
+ Agents, tasks, and users can switch their active workspace at runtime without reconnecting. The gateway updates the session's workspace subscription and returns a new `ConfigSnapshot` with the KV data for the new workspace.
637
+
638
+ ```typescript
639
+ // AgentClient — updates the agent's workspace subscription
640
+ await agent.connect();
641
+ agent.switchWorkspace("staging");
642
+ // agent.workspace === "staging"
643
+
644
+ // UserClient — declares the user's active app workspace to the gateway.
645
+ // Users do not encode a workspace in their identity (topic: us.{userId}.{windowId}),
646
+ // so calling switchWorkspace right after connect() is recommended to ensure
647
+ // server-side session state has the correct workspace for task-authority scoping.
648
+ await user.connect();
649
+ user.switchWorkspace("production"); // call immediately after connect
650
+
651
+ // TaskClient — same pattern as AgentClient
652
+ await task.connect();
653
+ task.switchWorkspace("prod-v2");
654
+ ```
655
+
656
+ **Signature** (same on `AgentClient`, `TaskClient`, `UserClient`):
657
+ ```typescript
658
+ switchWorkspace(newWorkspace: string): void
659
+ ```
660
+
661
+ - Fire-and-forget: the upstream `SwitchWorkspace` proto message is enqueued immediately; the local `workspace` property is updated synchronously.
662
+ - Throws `InvalidArgumentError` if `newWorkspace` is empty.
663
+ - No server ack is awaited — a new `ConfigSnapshot` downstream event will follow.
664
+
665
+ ## Key Architectural Principle
666
+
667
+ The connection itself IS the distributed lock AND the heartbeat. When the gRPC stream closes, the identity lock is immediately released on the server. No separate heartbeat API exists. This means:
668
+
669
+ - Each agent/unique-task identity can only have one active connection
670
+ - Disconnection automatically releases the identity for reuse
671
+ - Auto-reconnect with session resumption preserves the lock
672
+
673
+ ## API Reference
674
+
675
+ See the [Go SDK documentation](../go/aether/doc.go) and [Python SDK](../python-client/) for additional API patterns. This TypeScript SDK follows the same conventions.
676
+
677
+ ## License
678
+
679
+ Apache License, Version 2.0