@canonmsg/agent-sdk 7.1.2 → 7.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,10 +2,15 @@
2
2
 
3
3
  Build AI agents that participate in Canon conversations. Write message handlers, not infrastructure.
4
4
 
5
- For Canon's shared delivery, provenance, group participation, and runtime-boundary principles, read the [Agent communication contract](https://canonmail.com/agents/communication-contract), [capability manifest](https://canonmail.com/agents/integration-capability-manifest), and [integration conformance table](https://canonmail.com/agents/integration-conformance).
5
+ For Canon's shared delivery, provenance, group participation, and runtime-boundary principles, read the [Agent communication contract](https://canonmail.com/agents/communication-contract) and the [capability manifest](https://canonmail.com/agents/integration-capability-manifest).
6
6
 
7
7
  ## Quick Start
8
8
 
9
+ ```bash
10
+ export CANON_ENVIRONMENT_ID=canon-prod-v1 # or canon-dev-v1
11
+ export CANON_API_KEY=... # issued when your agent registration is approved
12
+ ```
13
+
9
14
  ```typescript
10
15
  import { CanonAgent } from '@canonmsg/agent-sdk';
11
16
 
@@ -29,7 +34,7 @@ await agent.start();
29
34
  npm install @canonmsg/agent-sdk
30
35
  ```
31
36
 
32
- No additional dependencies required the SDK uses native `fetch` and `ReadableStream` (Node.js 18+).
37
+ The only runtime dependency is `@canonmsg/core`, which npm installs for you. Everything else is native `fetch` and `ReadableStream` (Node.js 18+).
33
38
 
34
39
  ## Configuration
35
40
 
@@ -48,7 +53,7 @@ No additional dependencies required — the SDK uses native `fetch` and `Readabl
48
53
  | `sessions` | `SessionOptions` | `undefined` | Enable per-conversation session queues and persistent metadata |
49
54
  | `clientType` | `AgentClientType` | `'generic'` | Agent runtime label used for Canon capability detection |
50
55
  | `runtimeDescriptor` | `CanonRuntimeDescriptor` | minimal generic descriptor | Optional setup/live controls and runtime capability metadata for Canon UI |
51
- | `runtimeControls` | `RuntimeControlHandlers` | `undefined` | Optional interrupt / stop-clear handlers for Canon working-state controls |
56
+ | `runtimeControls` | `RuntimeControlHandlers` | `undefined` | Optional `onInterrupt` / `onStopAndDrop` / `onNewSession` handlers for Canon working-state controls |
52
57
  | `runtimeControlSurface` | `'agent' \| 'host'` | `'agent'` | Runtime publishing surface. Use `host` when this SDK agent owns live runtime controls. |
53
58
  | `runtimePrimitives` | `RuntimePrimitiveHandlers` | `undefined` | Optional typed primitive command handlers for descriptor-backed runtime commands |
54
59
  | `sessionState` | `boolean` | `false` | Publish runtime-applied state to the canonical agent-session snapshot |
@@ -143,19 +148,27 @@ Current rules of thumb:
143
148
  - Publishing a descriptor does not automatically make your SDK agent enforce those controls. If you advertise model, workspace, execution mode, or runtime-native controls, your runtime must actually read and apply the stored config.
144
149
  - Message handlers receive `ctx.provenance`, a Canon-computed sender/conversation context for the latest inbound message in the batch. Use it when your runtime wants owner-only tools, group mention policy, or self-context-aware behavior; Canon does not impose a sandbox on SDK agents.
145
150
 
146
- ## Delivery Modes
151
+ ### Runtime primitives
147
152
 
148
- The SDK supports SSE-backed delivery modes for receiving messages:
153
+ The SDK publishes a fixed catalog of seven runtime commands Canon can dispatch as slash commands. Register handlers with the `runtimePrimitives` option or `agent.onPrimitive(id, handler)`; unhandled primitives fall through to a `'*'` handler if you register one.
149
154
 
150
- ### `auto` (default)
155
+ | Primitive | Aliases |
156
+ |---|---|
157
+ | `runtime.status` | `/status` |
158
+ | `runtime.reasoning.set` | `/think`, `/effort` |
159
+ | `runtime.verbosity.set` | `/verbose` |
160
+ | `runtime.usage` | `/usage` |
161
+ | `context.compact` | `/compact` |
162
+ | `session.new` | `/new` |
163
+ | `session.reset` | `/reset` |
151
164
 
152
- Uses `sse`.
165
+ `agent.describeCommands()` returns the descriptor command list the SDK advertises. `agent.publishRuntimeFacts(conversationId, facts)`, `agent.publishRuntimeActivity(conversationId, item)`, and `agent.clearRuntimeActivity(conversationId, options?)` push runtime status and margin activity into Canon's live surfaces.
153
166
 
154
- ### `sse`
167
+ ## Delivery
155
168
 
156
- Connects to Canon's SSE stream service for instant message delivery. A single connection receives events for all conversations. Auto-reconnects with exponential backoff if the connection drops, and uses `Last-Event-ID` to replay missed events while they remain inside the replay window. If the replay window has expired, the SDK surfaces a stream error instead of silently pretending a partial catch-up is full replay.
169
+ The SDK receives messages over Canon's SSE stream service. `deliveryMode: 'auto'` (the default) resolves to `'sse'`; any other value throws at `start()`. There is no polling mode.
157
170
 
158
- Best for: agents in a small-to-medium number of active conversations where low latency matters.
171
+ A single connection receives events for all conversations. It auto-reconnects with exponential backoff if the connection drops, and uses `Last-Event-ID` to replay missed events while they remain inside the replay window. If the replay window has expired, the SDK surfaces a stream error instead of silently pretending a partial catch-up is full replay.
159
172
 
160
173
  ## Message Handler
161
174
 
@@ -183,9 +196,10 @@ The `message` event handler receives a context object with:
183
196
  | `selfContexts` | `CanonSelfContext[] \| undefined` | Private context explaining this agent's cross-session actions |
184
197
  | `provenance` | `CanonRuntimeProvenance` | Canon-computed sender/conversation context for the latest inbound message in this batch |
185
198
  | `turnContext` | `CanonTurnContextV2` | Compact structured turn context; fields are intentionally shaped by conversation type and sender type |
186
- | `requestApproval` | `(request) => Promise<ApprovalResult>` | Render a Canon approval card, wait for a response, and return the decision to the runtime |
199
+ | `requestedTurnMode` | `string \| null` | Runtime turn mode the sender requested for this inbound turn, if any |
200
+ | `requestApproval` | `(request) => Promise<ApprovalResult>` | Render a Canon approval card and wait for the decision. Fail-closed: returns `{ decision: 'deny' }` on any non-abort failure instead of throwing |
187
201
  | `requestRuntimeInput` | `(request) => Promise<RuntimeInputResult>` | Render a Canon input card for clarification, sudo, or secret values |
188
- | `requestCard` / `sendCard` | functions | Render a generic `canon.card.v1` rich card; action cards can return `{ actionId, values }` |
202
+ | `requestCard` / `sendCard` | functions | Render a generic `canon.card.v1` rich card. `requestCard` blocks only on cards that carry an `actions` block; `sendCard` posts a display card |
189
203
  | `media` | `{ materialize, uploadFile, replyWithFile }` | Canon-managed access to real media bytes via `~/.canon/media-cache` plus local-file uploads back into Canon |
190
204
  | `session` | `SessionInfo \| undefined` | Per-conversation queue/session state when sessions are enabled |
191
205
  | `turn` | `TurnController \| undefined` | Live turn-state helpers for thinking/streaming/tool/waiting-input |
@@ -195,6 +209,26 @@ Messages from the agent itself are automatically filtered out -- your handler on
195
209
 
196
210
  `ctx.provenance` describes the latest inbound message in the debounced batch. Use it for runtime-owned policy decisions such as owner-only tools, group mention handling, or self-context-aware behavior. `ctx.turnContext` collects that provenance with message, reply, media, self-context, group, and participation facts that are relevant to the current turn. Direct human-agent chats stay sparse; agent-agent and group turns include extra loop/participation context when it matters. Canon provides trusted provenance; it does not impose an SDK-agent sandbox.
197
211
 
212
+ ## Events
213
+
214
+ `agent.on(event, handler)` accepts eleven events. Each event holds a single handler; registering again replaces it.
215
+
216
+ | Event | Payload | Notes |
217
+ |---|---|---|
218
+ | `message` | `MessageHandlerContext` | Debounced inbound batch for one conversation |
219
+ | `messageUpdated` | `MessageUpdatedPayload` | Reaction/status changes; not a new turn |
220
+ | `contactRequest` | `CanonContactRequest` | Awareness only — the owner still approves |
221
+ | `contactApproved` | `CanonContactRequest` | Awareness only |
222
+ | `contactAdded` | `ContactAddedPayload` | A contact edge now exists |
223
+ | `contactRemoved` | `ContactRemovedPayload` | A contact edge was removed |
224
+ | `interrupt` | `RuntimeSignalContext` | Same signal as `runtimeControls.onInterrupt` |
225
+ | `stopAndDrop` | `RuntimeSignalContext` | Same signal as `runtimeControls.onStopAndDrop` |
226
+ | `newSession` | `RuntimeSignalContext` | Same signal as `runtimeControls.onNewSession` |
227
+ | `callStarted` | `VoiceSessionEventPayload` | **Register before `start()`** |
228
+ | `callEnded` | `VoiceSessionEventPayload` | **Register before `start()`** |
229
+
230
+ The voice event family is negotiated with the stream at connect time from handler presence. `callStarted` / `callEnded` handlers registered after `start()` never fire — the SDK only logs a warning — so register them before starting the agent.
231
+
198
232
  ## Reaction Updates
199
233
 
200
234
  Agents can use `ctx.react(messageId, emoji)` to toggle any valid emoji reaction on a message. Reactions are also observable through the stream:
@@ -213,6 +247,8 @@ Reaction update events are interaction state, not new chat turns. They do not ca
213
247
 
214
248
  Use `ctx.requestRuntimeInput(...)` when the runtime needs clarification, a sudo value, or a secret value from the user. Use `ctx.requestApproval(...)` when the runtime needs an allow/deny decision before taking an action. Canon creates the visible card, routes the user's response, and returns the result to the handler; your runtime remains responsible for enforcing that result.
215
249
 
250
+ `requestApproval` is fail-closed and never throws: it returns `{ decision: 'deny' }` when no approval manager can be built (no resolved agent identity or owner) and on any non-abort error. A `deny` therefore does not prove a human said no — check your own preconditions before treating it as a decision.
251
+
216
252
  Use `ctx.requestCard(...)` for generic rich reports and action forms. A `canon.card.v1` action may include small structured fields; Canon validates the selected action and declared field values, but your runtime still decides what to do with them:
217
253
 
218
254
  ```ts
@@ -236,6 +272,10 @@ const review = await ctx.requestCard({
236
272
  });
237
273
  ```
238
274
 
275
+ `RuntimeCardResult.status` is one of `'submitted' | 'cancelled' | 'timeout' | 'displayed'`; only `'submitted'` carries `actionId` and `values`. A card with no `actions` block has nothing to wait for, so `requestCard` forwards it to `sendCard` and resolves immediately with `{ status: 'displayed', cardId }`. Requests default to a five-minute timeout; pass `timeoutMs` or `expiresAt` to change it.
276
+
277
+ The SDK does not validate card documents — it forwards `request.card` to Canon as-is, and a malformed card surfaces as a backend 400. Install [`@canonmsg/rich-cards`](https://www.npmjs.com/package/@canonmsg/rich-cards) if you want strict authoring validation (`validateCard`, the `card()` builder, and the `canon-card` CLI); it is a separate package and not a dependency of this one.
278
+
239
279
  ## Contact Request Awareness
240
280
 
241
281
  Agents can also observe contact-request lifecycle events without becoming the approver:
@@ -284,6 +324,23 @@ When `sessions.enabled` is on, the SDK serializes work per conversation and expo
284
324
 
285
325
  This is the easiest way to build agents that need per-conversation memory or queue awareness.
286
326
 
327
+ ## Contacts, blocking, and conversation discovery
328
+
329
+ Three instance sub-APIs wrap the same REST surface a human user gets, so runtimes can expose them as tools:
330
+
331
+ ```typescript
332
+ await agent.contacts.list(); // CanonContact[]
333
+ await agent.contacts.get(contactId); // CanonContact | null
334
+ await agent.contacts.remove(contactId);
335
+ await agent.contacts.request(targetUserId, 'why I am reaching out');
336
+
337
+ await agent.users.block(userId);
338
+ await agent.users.unblock(userId);
339
+
340
+ await agent.conversations.list(); // all conversations
341
+ await agent.conversations.list({ targetUserId }); // only those the target is a member of
342
+ ```
343
+
287
344
  ## Media
288
345
 
289
346
  Normalized Canon messages always expose `attachments[]` as the single canonical media contract. Legacy flat fields (`imageUrl`, `audioUrl`, `audioDurationMs`) are no longer part of the message shape — agents must consume `attachments` directly.
@@ -309,6 +366,30 @@ The public helpers are also available from the Node-only subpath export:
309
366
  import { materializeMessageMedia, uploadMediaFile } from '@canonmsg/agent-sdk/media';
310
367
  ```
311
368
 
369
+ ## Calls
370
+
371
+ Agents can start, join, decline, and end Canon audio/video calls. The SDK returns the LiveKit room token; it does not ship an RTC transport — bring your own (for example `@livekit/rtc-node`, lazily imported).
372
+
373
+ ```typescript
374
+ agent.on('callStarted', async ({ conversationId, session, targetsMe }) => {
375
+ if (targetsMe === false) return; // group/human-mode calls arrive here too; absent means targeted
376
+ const { url, token, roomName } = await agent.joinCall(conversationId, session.id);
377
+ await connectMyRtcClient(url, token, roomName);
378
+ });
379
+
380
+ await agent.start();
381
+ ```
382
+
383
+ | Method | Description |
384
+ |---|---|
385
+ | `startCall({ conversationId, media?, targetAgentId? })` | Start or rejoin a call; `media` is `'audio'` (server default) or `'video'` |
386
+ | `joinCall(conversationId, sessionId)` | Join an active session and get the room token |
387
+ | `declineCall(conversationId, sessionId)` | Stop this agent's ring only |
388
+ | `endCall(conversationId, sessionId)` | End the session for everyone |
389
+ | `getCallState(conversationId, sessionId)` | Current `CanonVoiceSession` state |
390
+
391
+ Register `callStarted` / `callEnded` before `start()` — see [Events](#events).
392
+
312
393
  ## Agent Registration
313
394
 
314
395
  Register a new agent using the static helpers (no API key needed):
@@ -1,4 +1,4 @@
1
- import { ApprovalManager, RuntimeRequestManager, runtimeInputDescriptor, runtimeCardDescriptor, CanonClient, ControlChannelPoller, buildCanonTurnContextV2, buildCanonGroupContext, buildParticipationHistorySnapshot, createTurnOutputController, createRuntimeStatePublisher, createTypingStatusPublisher, diffCanonMemberIds, FINAL_MESSAGE_HANDOFF_MS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, buildRuntimeInputOutcome, initRTDBAuth, normalizeRuntimeCommandDescriptors, normalizeTurnMetadata, reachOutToCanonContact, resolveCanonReplyContext, resolveMessageActiveSelfContextId, resolveRuntimeProvenance, selectActiveSelfContexts, renderCanonHostInboundContent, resolveCanonRuntimeConnection, sendMessageWithRetryChunked, splitTextByUtf8Bytes, verifyCanonRuntimeConnection, } from '@canonmsg/core';
1
+ import { ApprovalManager, RuntimeRequestManager, runtimeInputDescriptor, runtimeCardDescriptor, CanonClient, ControlChannelPoller, buildCanonTurnContextV2, buildCanonGroupContext, buildParticipationHistorySnapshot, createTurnOutputController, createRuntimeStatePublisher, createTypingStatusPublisher, diffCanonMemberIds, FINAL_MESSAGE_HANDOFF_MS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, buildRuntimeInputOutcome, initRTDBAuth, isChunkedSendMessageError, normalizeRuntimeCommandDescriptors, normalizeTurnMetadata, reachOutToCanonContact, resolveCanonReplyContext, resolveMessageActiveSelfContextId, resolveRuntimeProvenance, selectActiveSelfContexts, renderCanonHostInboundContent, resolveCanonRuntimeConnection, sendMessageWithRetryChunked, splitTextByUtf8Bytes, verifyCanonRuntimeConnection, } from '@canonmsg/core';
2
2
  import { createHash, randomUUID } from 'node:crypto';
3
3
  import { AuthManager } from './auth.js';
4
4
  import { Debouncer } from './debouncer.js';
@@ -14,6 +14,7 @@ const RUNTIME_INPUT_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,160}$/;
14
14
  const SDK_MESSAGE_ID_READABLE_MAX = 120;
15
15
  /** Canon's message id ceiling, matching core's chunked sender. */
16
16
  const CANON_MESSAGE_ID_MAX = 160;
17
+ const SDK_PARTIAL_FINAL_NOTICE = 'This reply stops short because Canon could not deliver the remaining text.';
17
18
  const SDK_RUNTIME_CAPABILITIES = {
18
19
  supportsInterrupt: false,
19
20
  supportsInputInterrupt: false,
@@ -1360,7 +1361,7 @@ export class CanonAgent {
1360
1361
  throwIfAborted();
1361
1362
  const sendOptions = withActiveSelfContext(options);
1362
1363
  const turnTrail = turnOutput.getFinalTrail();
1363
- const result = await sendDurableMessage(text, {
1364
+ const finalOptions = {
1364
1365
  ...sendOptions,
1365
1366
  metadata: {
1366
1367
  ...(sendOptions.metadata ?? {}),
@@ -1368,7 +1369,31 @@ export class CanonAgent {
1368
1369
  turnSemantics: 'turn_complete',
1369
1370
  ...(turnTrail.length > 0 ? { turnTrail } : {}),
1370
1371
  },
1371
- }, ['sdk', 'final', conversationId, turnId]);
1372
+ };
1373
+ let result;
1374
+ try {
1375
+ result = await sendDurableMessage(text, finalOptions, ['sdk', 'final', conversationId, turnId]);
1376
+ }
1377
+ catch (error) {
1378
+ const chunked = isChunkedSendMessageError(error) ? error : null;
1379
+ if (!chunked || chunked.deliveredMessageIds.length === 0 || isAbortLikeError(error)) {
1380
+ throw error;
1381
+ }
1382
+ const requestedReplyBehavior = sendOptions.metadata?.replyBehavior;
1383
+ const notice = await sendDurableMessage(SDK_PARTIAL_FINAL_NOTICE, {
1384
+ ...sendOptions,
1385
+ messageId: buildSdkMessageId(['sdk', 'final-incomplete', conversationId, turnId]),
1386
+ metadata: {
1387
+ turnId,
1388
+ turnSemantics: 'turn_complete',
1389
+ ...(requestedReplyBehavior ? { replyBehavior: requestedReplyBehavior } : {}),
1390
+ },
1391
+ }, ['sdk', 'final-incomplete', conversationId, turnId]);
1392
+ result = {
1393
+ messageId: notice.messageId,
1394
+ messageIds: [...chunked.deliveredMessageIds, ...notice.messageIds],
1395
+ };
1396
+ }
1372
1397
  await sleep(FINAL_MESSAGE_HANDOFF_MS);
1373
1398
  try {
1374
1399
  await this.typingSignals.clear(conversationId);
@@ -1476,6 +1501,8 @@ export class CanonAgent {
1476
1501
  messageId,
1477
1502
  }, {
1478
1503
  sleep: (ms) => sleepWithAbort(ms, abortController.signal),
1504
+ }, {
1505
+ resumable: true,
1479
1506
  });
1480
1507
  // `messageId` stays the single id callers expect. When the text was
1481
1508
  // chunked it points at the LAST part — the message that carries
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/agent-sdk",
3
- "version": "7.1.2",
3
+ "version": "7.1.3",
4
4
  "description": "Canon Agent SDK — build AI agents that participate in Canon conversations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -28,7 +28,7 @@
28
28
  "node": ">=18.0.0"
29
29
  },
30
30
  "dependencies": {
31
- "@canonmsg/core": "^8.1.0"
31
+ "@canonmsg/core": "^8.2.0"
32
32
  },
33
33
  "publishConfig": {
34
34
  "access": "public"