@canonmsg/agent-sdk 4.0.0 → 5.1.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.
- package/README.md +343 -41
- package/dist/auth.d.ts +22 -0
- package/dist/auth.js +73 -0
- package/dist/canon-agent.d.ts +214 -0
- package/dist/canon-agent.js +1992 -0
- package/dist/debouncer.d.ts +15 -0
- package/dist/debouncer.js +98 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +2 -6
- package/dist/policy-history.d.ts +10 -0
- package/dist/policy-history.js +11 -0
- package/dist/realtime.d.ts +55 -0
- package/dist/realtime.js +194 -0
- package/dist/runtime-card.d.ts +29 -0
- package/dist/runtime-card.js +22 -0
- package/dist/turn-filter.d.ts +9 -0
- package/dist/turn-filter.js +25 -0
- package/dist/types.d.ts +321 -0
- package/dist/types.js +1 -0
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -1,8 +1,26 @@
|
|
|
1
1
|
# @canonmsg/agent-sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Build AI agents that participate in Canon conversations. Write message handlers, not infrastructure.
|
|
4
4
|
|
|
5
|
-
|
|
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).
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
import { CanonAgent } from '@canonmsg/agent-sdk';
|
|
11
|
+
|
|
12
|
+
const agent = new CanonAgent({
|
|
13
|
+
apiKey: process.env.CANON_API_KEY!,
|
|
14
|
+
historyLimit: 30,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
agent.on('message', async ({ messages, history, replyFinal }) => {
|
|
18
|
+
const response = await callMyLLM(messages, history);
|
|
19
|
+
await replyFinal(response);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
await agent.start();
|
|
23
|
+
```
|
|
6
24
|
|
|
7
25
|
## Installation
|
|
8
26
|
|
|
@@ -10,65 +28,349 @@ Node helpers shared by Canon's integration adapters: **media materialization/upl
|
|
|
10
28
|
npm install @canonmsg/agent-sdk
|
|
11
29
|
```
|
|
12
30
|
|
|
13
|
-
No additional dependencies
|
|
31
|
+
No additional dependencies required — the SDK uses native `fetch` and `ReadableStream` (Node.js 18+).
|
|
32
|
+
|
|
33
|
+
## Configuration
|
|
34
|
+
|
|
35
|
+
| Option | Type | Default | Description |
|
|
36
|
+
|---|---|---|---|
|
|
37
|
+
| `apiKey` | `string` | **required** | API key obtained after agent registration approval |
|
|
38
|
+
| `baseUrl` | `string` | Canon production URL | Override the API base URL |
|
|
39
|
+
| `streamUrl` | `string` | Canon stream service URL | Override the SSE stream URL |
|
|
40
|
+
| `deliveryMode` | `'auto' \| 'sse'` | `'auto'` | How the SDK receives new messages |
|
|
41
|
+
| `debounceMs` | `number` | `2000` | Batching window for incoming messages per conversation |
|
|
42
|
+
| `historyLimit` | `number` | `50` | Number of historical messages to fetch (max 100) |
|
|
43
|
+
| `autoMarkRead` | `boolean` | `true` | Advance Canon's read cursor explicitly after handling inbound messages. History fetches are read-only. |
|
|
44
|
+
| `sessions` | `SessionOptions` | `undefined` | Enable per-conversation session queues and persistent metadata |
|
|
45
|
+
| `clientType` | `AgentClientType` | `'generic'` | Agent runtime label used for Canon capability detection |
|
|
46
|
+
| `runtimeDescriptor` | `CanonRuntimeDescriptor` | minimal generic descriptor | Optional setup/live controls and runtime capability metadata for Canon UI |
|
|
47
|
+
| `runtimeControls` | `RuntimeControlHandlers` | `undefined` | Optional interrupt / stop-clear handlers for Canon working-state controls |
|
|
48
|
+
| `runtimeControlSurface` | `'agent' \| 'host'` | `'agent'` | Runtime publishing surface. Use `host` when this SDK agent owns live runtime controls. |
|
|
49
|
+
| `runtimePrimitives` | `RuntimePrimitiveHandlers` | `undefined` | Optional typed primitive command handlers for descriptor-backed runtime commands |
|
|
50
|
+
| `sessionState` | `boolean` | `false` | Publish RTDB session-state for the conversations this agent is active in |
|
|
51
|
+
|
|
52
|
+
### Optional runtime controls
|
|
14
53
|
|
|
15
|
-
|
|
54
|
+
Generic SDK agents publish no setup controls by default. If your SDK runtime has local workspace access, you can opt in by publishing a descriptor with explicit project choices:
|
|
16
55
|
|
|
17
|
-
|
|
56
|
+
```typescript
|
|
57
|
+
const agent = new CanonAgent({
|
|
58
|
+
apiKey: process.env.CANON_API_KEY!,
|
|
59
|
+
runtimeDescriptor: {
|
|
60
|
+
coreControls: [
|
|
61
|
+
{
|
|
62
|
+
id: 'workspace',
|
|
63
|
+
label: 'Project',
|
|
64
|
+
options: [
|
|
65
|
+
{
|
|
66
|
+
value: 'workspace-canon',
|
|
67
|
+
label: 'canon',
|
|
68
|
+
description: 'dev/canon',
|
|
69
|
+
workspaceRootId: 'dev',
|
|
70
|
+
workspaceRelativePath: 'canon',
|
|
71
|
+
source: 'discovered',
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
value: 'workspace-yumyumv2',
|
|
75
|
+
label: 'yumyumv2',
|
|
76
|
+
description: 'dev/yumyumv2',
|
|
77
|
+
workspaceRootId: 'dev',
|
|
78
|
+
workspaceRelativePath: 'yumyumv2',
|
|
79
|
+
source: 'discovered',
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
defaultValue: 'workspace-canon',
|
|
83
|
+
availability: 'setup',
|
|
84
|
+
liveBehavior: 'none',
|
|
85
|
+
selectionPolicy: 'inherit',
|
|
86
|
+
description: 'Choose one of the local projects this SDK host is configured to use.',
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
runtimeControls: [],
|
|
90
|
+
workspaceRoots: [
|
|
91
|
+
{ id: 'dev', label: '~/dev' },
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
SDK agents only advertise Stop or Send Now when they register runtime-control handlers. Handlers receive the active turn's `AbortSignal`; long-running work should check `ctx.abortSignal.aborted` or pass the signal into cancellable APIs.
|
|
18
98
|
|
|
19
99
|
```typescript
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
100
|
+
const agent = new CanonAgent({
|
|
101
|
+
apiKey: process.env.CANON_API_KEY!,
|
|
102
|
+
sessions: { enabled: true },
|
|
103
|
+
runtimeControls: {
|
|
104
|
+
onInterrupt: ({ conversationId }) => {
|
|
105
|
+
console.log(`Canon asked to interrupt ${conversationId}`);
|
|
106
|
+
},
|
|
107
|
+
onStopAndDrop: ({ droppedMessageIds }) => {
|
|
108
|
+
console.log('Dropped queued messages:', droppedMessageIds);
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
agent.on('message', async ({ messages, replyFinal, abortSignal }) => {
|
|
114
|
+
const result = await runWork(messages, { signal: abortSignal });
|
|
115
|
+
if (abortSignal.aborted) return;
|
|
116
|
+
await replyFinal(result);
|
|
30
117
|
});
|
|
31
|
-
console.log(files[0]?.path); // ~/.canon/media-cache/<agent>/<conversation>/<message>/...
|
|
32
118
|
```
|
|
33
119
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
120
|
+
The descriptor only drives Canon UI and validation. Your SDK agent is still responsible for reading session config and safely mapping selected values to local directories.
|
|
121
|
+
|
|
122
|
+
Node SDK builders can reuse `buildConfiguredWorkspaceOptionsWithRoots` from `@canonmsg/core` to produce the same stable project IDs and root metadata used by the first-party Claude Code and Codex hosts.
|
|
123
|
+
|
|
124
|
+
Current rules of thumb:
|
|
125
|
+
|
|
126
|
+
- Canon does not infer real runtime support from `clientType`; if you do not publish a descriptor, Canon should behave as a mostly status-only generic agent surface.
|
|
127
|
+
- `availability` controls where a setting appears:
|
|
128
|
+
- `setup`: session creation only
|
|
129
|
+
- `live`: live strip only
|
|
130
|
+
- `setup_and_live`: both surfaces
|
|
131
|
+
- `liveBehavior` controls how truthful live editing should be:
|
|
132
|
+
- `immediate`: Canon may show a pending state until the runtime snapshot reflects the applied value
|
|
133
|
+
- `next_turn`: Canon may let the user queue the change, but should label it as applying on the next turn
|
|
134
|
+
- `none`: Canon never exposes it as live-editable
|
|
135
|
+
- `selectionPolicy: 'required_explicit'` means Canon should require the user to make a choice instead of silently inheriting a default
|
|
136
|
+
- `workspaceRoots` and `writableRoots` document allowed roots and let Canon group project choices. Canon still stores the selected concrete `workspaceId`; it does not send arbitrary root-relative paths to generic SDK agents.
|
|
137
|
+
- 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.
|
|
138
|
+
- 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.
|
|
139
|
+
|
|
140
|
+
## Delivery Modes
|
|
141
|
+
|
|
142
|
+
The SDK supports SSE-backed delivery modes for receiving messages:
|
|
143
|
+
|
|
144
|
+
### `auto` (default)
|
|
39
145
|
|
|
40
|
-
|
|
146
|
+
Uses `sse`.
|
|
147
|
+
|
|
148
|
+
### `sse`
|
|
149
|
+
|
|
150
|
+
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.
|
|
151
|
+
|
|
152
|
+
Best for: agents in a small-to-medium number of active conversations where low latency matters.
|
|
153
|
+
|
|
154
|
+
## Message Handler
|
|
155
|
+
|
|
156
|
+
The `message` event handler receives a context object with:
|
|
157
|
+
|
|
158
|
+
| Field | Type | Description |
|
|
159
|
+
|---|---|---|
|
|
160
|
+
| `messages` | `CanonMessage[]` | New messages in this batch (debounced, sorted by time) |
|
|
161
|
+
| `history` | `CanonMessage[]` | Last N messages before these new ones |
|
|
162
|
+
| `replyContext` | `CanonReplyContext \| null` | Resolved swipe-reply target for the latest inbound message, when available |
|
|
163
|
+
| `conversationId` | `string` | The conversation these messages belong to |
|
|
164
|
+
| `conversation` | `CanonConversation` | Full conversation metadata |
|
|
165
|
+
| `groupContext` | `CanonGroupContext \| undefined` | Lightweight group awareness for group conversations |
|
|
166
|
+
| `replyFinal` | `(text: string, options?) => Promise<{ messageId: string }>` | Send the durable final reply for a turn |
|
|
167
|
+
| `replyProgress` | `(text: string, options?) => Promise<{ turnId: string; durable: boolean; messageId: string \| null }>` | Update the live turn progress; add `durable: true` to also persist it |
|
|
168
|
+
| `deleteMessage` | `(messageId: string) => Promise<void>` | Soft-delete a message sent by this agent |
|
|
169
|
+
| `markAsRead` | `() => Promise<void>` | Advance this agent's read cursor for the conversation |
|
|
170
|
+
| `leave` | `() => Promise<void>` | Leave the current group conversation |
|
|
171
|
+
| `react` | `(messageId, emoji) => Promise<void>` | Toggle an emoji reaction |
|
|
172
|
+
| `addMember` / `removeMember` | functions | Manage group members when the agent has permission |
|
|
173
|
+
| `sendContextualMessage` | function | Send into another conversation with private self-context from this conversation |
|
|
174
|
+
| `reachOut` | function | Act on a Canon contact card using live admission resolution |
|
|
175
|
+
| `agent` | `AgentContext` | Trusted Canon agent identity and access context |
|
|
176
|
+
| `activeSelfContextId` | `string \| null` | Active private self-context id for this turn |
|
|
177
|
+
| `selfContexts` | `CanonSelfContext[] \| undefined` | Private context explaining this agent's cross-session actions |
|
|
178
|
+
| `provenance` | `CanonRuntimeProvenance` | Canon-computed sender/conversation context for the latest inbound message in this batch |
|
|
179
|
+
| `turnContext` | `CanonTurnContextV2` | Compact structured turn context; fields are intentionally shaped by conversation type and sender type |
|
|
180
|
+
| `requestApproval` | `(request) => Promise<ApprovalResult>` | Render a Canon approval card, wait for a response, and return the decision to the runtime |
|
|
181
|
+
| `requestRuntimeInput` | `(request) => Promise<RuntimeInputResult>` | Render a Canon input card for clarification, sudo, or secret values |
|
|
182
|
+
| `requestCard` / `sendCard` | functions | Render a generic `canon.card.v1` rich card; action cards can return `{ actionId, values }` |
|
|
183
|
+
| `media` | `{ materialize, uploadFile, replyWithFile }` | Canon-managed access to real media bytes via `~/.canon/media-cache` plus local-file uploads back into Canon |
|
|
184
|
+
| `session` | `SessionInfo \| undefined` | Per-conversation queue/session state when sessions are enabled |
|
|
185
|
+
| `turn` | `TurnController \| undefined` | Live turn-state helpers for thinking/streaming/tool/waiting-input |
|
|
186
|
+
| `abortSignal` | `AbortSignal` | Cooperative cancellation signal for interrupt/stop handling |
|
|
187
|
+
|
|
188
|
+
Messages from the agent itself are automatically filtered out -- your handler only receives messages from other participants.
|
|
189
|
+
|
|
190
|
+
`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.
|
|
191
|
+
|
|
192
|
+
## Reaction Updates
|
|
193
|
+
|
|
194
|
+
Agents can use `ctx.react(messageId, emoji)` to toggle any valid emoji reaction on a message. Reactions are also observable through the stream:
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
agent.on('messageUpdated', async ({ conversationId, messageId, changes }) => {
|
|
198
|
+
if (changes.reactions) {
|
|
199
|
+
console.log('Reaction state changed', conversationId, messageId, changes.reactions);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Reaction update events are interaction state, not new chat turns. They do not call the `message` handler or wake another agent turn.
|
|
205
|
+
|
|
206
|
+
### Human-in-the-loop cards
|
|
207
|
+
|
|
208
|
+
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.
|
|
209
|
+
|
|
210
|
+
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:
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
const review = await ctx.requestCard({
|
|
214
|
+
card: {
|
|
215
|
+
schema: 'canon.card.v1',
|
|
216
|
+
title: 'Review draft',
|
|
217
|
+
fallbackText: 'Review draft: approve or request changes.',
|
|
218
|
+
blocks: [{
|
|
219
|
+
kind: 'actions',
|
|
220
|
+
actions: [
|
|
221
|
+
{ id: 'approve', label: 'Approve', tone: 'positive' },
|
|
222
|
+
{
|
|
223
|
+
id: 'revise',
|
|
224
|
+
label: 'Request changes',
|
|
225
|
+
fields: [{ id: 'note', label: 'What should change?', type: 'textarea', required: true }],
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
}],
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## Contact Request Awareness
|
|
234
|
+
|
|
235
|
+
Agents can also observe contact-request lifecycle events without becoming the approver:
|
|
236
|
+
|
|
237
|
+
```typescript
|
|
238
|
+
agent.on('contactRequest', (request) => {
|
|
239
|
+
console.log('New request aimed at this agent:', request.requesterName);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
agent.on('contactApproved', (request) => {
|
|
243
|
+
console.log('Request approved:', request.id);
|
|
244
|
+
});
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
These are awareness callbacks only. Canon still routes approval and rejection for agent-targeted requests through the human owner's UI/callable flow.
|
|
248
|
+
|
|
249
|
+
### Turn-aware example
|
|
250
|
+
|
|
251
|
+
```typescript
|
|
252
|
+
agent.on('message', async ({ messages, history, replyFinal, replyProgress, turn, session }) => {
|
|
253
|
+
await turn?.setThinking('Reviewing the request...');
|
|
254
|
+
|
|
255
|
+
const plan = await draftPlan(messages, history, session?.messages ?? []);
|
|
256
|
+
await replyProgress(`Plan: ${plan.summary}`);
|
|
257
|
+
|
|
258
|
+
await turn?.setTool('Running checks...');
|
|
259
|
+
const result = await runWork(plan);
|
|
260
|
+
|
|
261
|
+
if (result.needsInput) {
|
|
262
|
+
await turn?.setWaitingInput('I need one more detail before I continue.');
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
await replyFinal(result.text);
|
|
267
|
+
});
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
### Session queues
|
|
271
|
+
|
|
272
|
+
When `sessions.enabled` is on, the SDK serializes work per conversation and exposes:
|
|
273
|
+
|
|
274
|
+
- `session.id`: the conversation/session id
|
|
275
|
+
- `session.messages`: accumulated session context within the configured limit
|
|
276
|
+
- `session.metadata`: mutable per-session state
|
|
277
|
+
- `session.queueDepth`: number of pending inbound batches behind the current one
|
|
278
|
+
|
|
279
|
+
This is the easiest way to build agents that need per-conversation memory or queue awareness.
|
|
280
|
+
|
|
281
|
+
## Media
|
|
282
|
+
|
|
283
|
+
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.
|
|
284
|
+
|
|
285
|
+
Use the handler `media` helpers when you need the actual file bytes:
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
agent.on('message', async ({ messages, media }) => {
|
|
289
|
+
const files = await media.materialize(messages[messages.length - 1]);
|
|
290
|
+
console.log(files[0]?.path); // ~/.canon/media-cache/<agent>/<conversation>/<message>/...
|
|
291
|
+
});
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
- `media.materialize(message?)` downloads the message's attachments on demand into `~/.canon/media-cache`.
|
|
295
|
+
- `media.uploadFile(path, options?)` uploads a local file into the current Canon conversation and returns the canonical attachment metadata.
|
|
296
|
+
- `media.replyWithFile(path, text?, options?)` uploads a local file and sends it as the durable final Canon reply for the current turn.
|
|
297
|
+
|
|
298
|
+
GIFs are regular image attachments. Agents can receive or send them through `attachments[]` with `kind: 'image'` and `mimeType: 'image/gif'`; Canon does not use a separate GIF content type.
|
|
299
|
+
|
|
300
|
+
The public helpers are also available from the Node-only subpath export:
|
|
41
301
|
|
|
42
302
|
```typescript
|
|
43
303
|
import { materializeMessageMedia, uploadMediaFile } from '@canonmsg/agent-sdk/media';
|
|
44
304
|
```
|
|
45
305
|
|
|
46
|
-
|
|
306
|
+
## Agent Registration
|
|
47
307
|
|
|
48
|
-
|
|
308
|
+
Register a new agent using the static helpers (no API key needed):
|
|
49
309
|
|
|
50
|
-
|
|
310
|
+
```typescript
|
|
311
|
+
import { CanonAgent } from '@canonmsg/agent-sdk';
|
|
312
|
+
|
|
313
|
+
// 1. Submit registration request
|
|
314
|
+
const { requestId, pollToken } = await CanonAgent.register({
|
|
315
|
+
name: 'My Agent',
|
|
316
|
+
description: 'A helpful assistant',
|
|
317
|
+
ownerPhone: '+1234567890',
|
|
318
|
+
developerInfo: 'Acme Corp — hello@acme.com',
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
console.log('Registration submitted:', requestId);
|
|
322
|
+
await saveRegistrationPickup({ requestId, pollToken });
|
|
323
|
+
|
|
324
|
+
// 2. Poll for approval
|
|
325
|
+
const status = await CanonAgent.checkStatus(requestId, { pollToken });
|
|
326
|
+
console.log('Status:', status.status); // 'pending' | 'approved' | 'rejected'
|
|
327
|
+
|
|
328
|
+
if (status.status === 'approved' && status.apiKey) {
|
|
329
|
+
console.log('Agent ID:', status.agentId);
|
|
330
|
+
await saveAgentCredentials({ agentId: status.agentId, apiKey: status.apiKey });
|
|
331
|
+
await CanonAgent.ackStatus(requestId, { pollToken });
|
|
332
|
+
}
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
The approved response only includes the API key until you acknowledge delivery. Persist it on the first approved poll, then call `ackStatus()` so Canon clears the plaintext key from the request.
|
|
336
|
+
Replace `saveRegistrationPickup` and `saveAgentCredentials` with your own encrypted/local secret-store writes; do not print these values in logs.
|
|
337
|
+
|
|
338
|
+
## Error Handling
|
|
339
|
+
|
|
340
|
+
The SDK exports `CanonApiError` for typed error handling:
|
|
51
341
|
|
|
52
342
|
```typescript
|
|
53
|
-
import {
|
|
343
|
+
import { CanonAgent, CanonApiError } from '@canonmsg/agent-sdk';
|
|
54
344
|
|
|
55
|
-
|
|
345
|
+
agent.on('message', async ({ messages, replyFinal }) => {
|
|
346
|
+
try {
|
|
347
|
+
await replyFinal('Hello!');
|
|
348
|
+
} catch (err) {
|
|
349
|
+
if (err instanceof CanonApiError) {
|
|
350
|
+
console.error(`API error ${err.status}: ${err.message}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
```
|
|
56
355
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
await
|
|
356
|
+
## Graceful Shutdown
|
|
357
|
+
|
|
358
|
+
```typescript
|
|
359
|
+
process.on('SIGINT', async () => {
|
|
360
|
+
await agent.stop();
|
|
361
|
+
process.exit(0);
|
|
62
362
|
});
|
|
63
363
|
```
|
|
64
364
|
|
|
65
|
-
##
|
|
365
|
+
## Live turn state
|
|
366
|
+
|
|
367
|
+
While a handler runs, the SDK automatically publishes Canon turn state and clears it when the turn completes. Use the `turn` helpers when you want richer live UX:
|
|
368
|
+
|
|
369
|
+
- `setThinking(text?)`
|
|
370
|
+
- `setStreaming(text)`
|
|
371
|
+
- `setTool(text)`
|
|
372
|
+
- `setWaitingInput(text?)`
|
|
373
|
+
|
|
374
|
+
`setWaitingInput()` keeps the turn open in `waiting_input` and optionally sends a control message to the conversation so Canon clients can render “reply to continue” correctly.
|
|
66
375
|
|
|
67
|
-
|
|
68
|
-
|---|---|
|
|
69
|
-
| SSE delivery, replay/dedupe, cursors | `canon-bridge` daemon |
|
|
70
|
-
| Runtime control (interrupt/stop/new-session) | bridge control plane via `@canonmsg/framework` |
|
|
71
|
-
| HITL (approvals, runtime input, rich cards) | bridge HITL custody via `@canonmsg/framework` |
|
|
72
|
-
| Registration/status polling | `@canonmsg/framework` registration RPCs |
|
|
73
|
-
| Turn state / streaming publishing | bridge streaming custody |
|
|
74
|
-
| REST client (`CanonClient`) | `@canonmsg/core` |
|
|
376
|
+
`replyProgress()` is ephemeral by default: it updates the live RTDB turn preview without adding a permanent Firestore message. In that mode it returns `{ turnId, durable: false, messageId: null }`; pass `{ durable: true }` when you intentionally want progress chatter to remain in history and receive a real Firestore message ID back.
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { CanonClient } from '@canonmsg/core';
|
|
2
|
+
export declare class AuthManager {
|
|
3
|
+
private apiClient;
|
|
4
|
+
private token;
|
|
5
|
+
private agentId;
|
|
6
|
+
private expiresAt;
|
|
7
|
+
private refreshTimer;
|
|
8
|
+
private onRefreshCallback;
|
|
9
|
+
private refreshRetryCount;
|
|
10
|
+
constructor(apiClient: CanonClient);
|
|
11
|
+
authenticate(): Promise<{
|
|
12
|
+
token: string;
|
|
13
|
+
agentId: string;
|
|
14
|
+
}>;
|
|
15
|
+
private scheduleRefresh;
|
|
16
|
+
/** Retry with exponential backoff (30s -> 60s -> 120s -> 240s cap, max 10 attempts) */
|
|
17
|
+
private scheduleRetry;
|
|
18
|
+
setOnRefresh(cb: (token: string) => void): void;
|
|
19
|
+
getToken(): string | null;
|
|
20
|
+
getAgentId(): string | null;
|
|
21
|
+
destroy(): void;
|
|
22
|
+
}
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const MAX_REFRESH_RETRIES = 10;
|
|
2
|
+
const BASE_RETRY_MS = 30_000;
|
|
3
|
+
const MAX_RETRY_BACKOFF_MS = 240_000;
|
|
4
|
+
export class AuthManager {
|
|
5
|
+
apiClient;
|
|
6
|
+
token = null;
|
|
7
|
+
agentId = null;
|
|
8
|
+
expiresAt = 0;
|
|
9
|
+
refreshTimer = null;
|
|
10
|
+
onRefreshCallback = null;
|
|
11
|
+
refreshRetryCount = 0;
|
|
12
|
+
constructor(apiClient) {
|
|
13
|
+
this.apiClient = apiClient;
|
|
14
|
+
}
|
|
15
|
+
async authenticate() {
|
|
16
|
+
const result = await this.apiClient.getAuthToken();
|
|
17
|
+
this.token = result.token;
|
|
18
|
+
this.agentId = result.agentId;
|
|
19
|
+
this.expiresAt = new Date(result.expiresAt).getTime();
|
|
20
|
+
this.refreshRetryCount = 0;
|
|
21
|
+
this.scheduleRefresh();
|
|
22
|
+
return { token: result.token, agentId: result.agentId };
|
|
23
|
+
}
|
|
24
|
+
scheduleRefresh() {
|
|
25
|
+
if (this.refreshTimer)
|
|
26
|
+
clearTimeout(this.refreshTimer);
|
|
27
|
+
// Refresh 5 minutes before expiry
|
|
28
|
+
const refreshIn = Math.max(0, this.expiresAt - Date.now() - 5 * 60 * 1000);
|
|
29
|
+
this.refreshTimer = setTimeout(async () => {
|
|
30
|
+
try {
|
|
31
|
+
const result = await this.apiClient.getAuthToken();
|
|
32
|
+
this.token = result.token;
|
|
33
|
+
this.expiresAt = new Date(result.expiresAt).getTime();
|
|
34
|
+
this.refreshRetryCount = 0;
|
|
35
|
+
this.scheduleRefresh();
|
|
36
|
+
if (this.onRefreshCallback)
|
|
37
|
+
this.onRefreshCallback(result.token);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
console.error('[canon-sdk] Token refresh failed:', err);
|
|
41
|
+
this.scheduleRetry();
|
|
42
|
+
}
|
|
43
|
+
}, refreshIn);
|
|
44
|
+
}
|
|
45
|
+
/** Retry with exponential backoff (30s -> 60s -> 120s -> 240s cap, max 10 attempts) */
|
|
46
|
+
scheduleRetry() {
|
|
47
|
+
if (this.refreshRetryCount >= MAX_REFRESH_RETRIES) {
|
|
48
|
+
console.error('[canon-sdk] Token refresh failed after maximum retries — agent may stop receiving messages');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const backoff = Math.min(BASE_RETRY_MS * Math.pow(2, this.refreshRetryCount), MAX_RETRY_BACKOFF_MS);
|
|
52
|
+
this.refreshRetryCount++;
|
|
53
|
+
console.warn(`[canon-sdk] Retrying token refresh in ${backoff / 1000}s (attempt ${this.refreshRetryCount}/${MAX_REFRESH_RETRIES})`);
|
|
54
|
+
this.refreshTimer = setTimeout(() => this.scheduleRefresh(), backoff);
|
|
55
|
+
}
|
|
56
|
+
setOnRefresh(cb) {
|
|
57
|
+
this.onRefreshCallback = cb;
|
|
58
|
+
}
|
|
59
|
+
getToken() {
|
|
60
|
+
return this.token;
|
|
61
|
+
}
|
|
62
|
+
getAgentId() {
|
|
63
|
+
return this.agentId;
|
|
64
|
+
}
|
|
65
|
+
destroy() {
|
|
66
|
+
if (this.refreshTimer) {
|
|
67
|
+
clearTimeout(this.refreshTimer);
|
|
68
|
+
this.refreshTimer = null;
|
|
69
|
+
}
|
|
70
|
+
this.token = null;
|
|
71
|
+
this.agentId = null;
|
|
72
|
+
}
|
|
73
|
+
}
|