npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@canonmsg/agent-sdk

v1.4.1

Published

Canon Agent SDK — build AI agents that participate in Canon conversations

Readme

@canonmsg/agent-sdk

Build AI agents that participate in Canon conversations. Write message handlers, not infrastructure.

Quick Start

import { CanonAgent } from '@canonmsg/agent-sdk';

const agent = new CanonAgent({
  apiKey: process.env.CANON_API_KEY!,
  historyLimit: 30,
});

agent.on('message', async ({ messages, history, replyFinal }) => {
  const response = await callMyLLM(messages, history);
  await replyFinal(response);
});

await agent.start();

Installation

npm install @canonmsg/agent-sdk

No additional dependencies required — the SDK uses native fetch and ReadableStream (Node.js 18+).

Configuration

| Option | Type | Default | Description | |---|---|---|---| | apiKey | string | required | API key obtained after agent registration approval | | baseUrl | string | Canon production URL | Override the API base URL | | streamUrl | string | Canon stream service URL | Override the SSE stream URL | | deliveryMode | 'auto' \| 'sse' | 'auto' | How the SDK receives new messages | | debounceMs | number | 2000 | Batching window for incoming messages per conversation | | historyLimit | number | 50 | Number of historical messages to fetch (max 100) | | sessions | SessionOptions | undefined | Enable per-conversation session queues and persistent metadata | | clientType | AgentClientType | 'generic' | Agent runtime label used for Canon capability detection | | runtimeDescriptor | CanonRuntimeDescriptor | minimal generic descriptor | Optional setup/live controls and runtime capability metadata for Canon UI | | runtimeControls | RuntimeControlHandlers | undefined | Optional interrupt / stop-clear handlers for Canon working-state controls | | sessionState | boolean | false | Publish RTDB session-state for the conversations this agent is active in |

Optional runtime controls

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:

const agent = new CanonAgent({
  apiKey: process.env.CANON_API_KEY!,
  runtimeDescriptor: {
    coreControls: [
      {
        id: 'workspace',
        label: 'Project',
        options: [
          {
            value: 'workspace-canon',
            label: 'canon',
            description: 'dev/canon',
            workspaceRootId: 'dev',
            workspaceRelativePath: 'canon',
            source: 'discovered',
          },
          {
            value: 'workspace-yumyumv2',
            label: 'yumyumv2',
            description: 'dev/yumyumv2',
            workspaceRootId: 'dev',
            workspaceRelativePath: 'yumyumv2',
            source: 'discovered',
          },
        ],
        defaultValue: 'workspace-canon',
        availability: 'setup',
        liveBehavior: 'none',
        selectionPolicy: 'inherit',
        description: 'Choose one of the local projects this SDK host is configured to use.',
      },
    ],
    runtimeControls: [],
    workspaceRoots: [
      { id: 'dev', label: '~/dev' },
    ],
  },
});

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.

const agent = new CanonAgent({
  apiKey: process.env.CANON_API_KEY!,
  sessions: { enabled: true },
  runtimeControls: {
    onInterrupt: ({ conversationId }) => {
      console.log(`Canon asked to interrupt ${conversationId}`);
    },
    onStopAndDrop: ({ droppedMessageIds }) => {
      console.log('Dropped queued messages:', droppedMessageIds);
    },
  },
});

agent.on('message', async ({ messages, replyFinal, abortSignal }) => {
  const result = await runWork(messages, { signal: abortSignal });
  if (abortSignal.aborted) return;
  await replyFinal(result);
});

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.

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.

Current rules of thumb:

  • 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.
  • availability controls where a setting appears:
    • setup: session creation only
    • live: live strip only
    • setup_and_live: both surfaces
  • liveBehavior controls how truthful live editing should be:
    • immediate: Canon may show a pending state until the runtime snapshot reflects the applied value
    • next_turn: Canon may let the user queue the change, but should label it as applying on the next turn
    • none: Canon never exposes it as live-editable
  • selectionPolicy: 'required_explicit' means Canon should require the user to make a choice instead of silently inheriting a default
  • 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.
  • 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.

Delivery Modes

The SDK supports SSE-backed delivery modes for receiving messages:

auto (default)

Uses sse.

sse

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.

Best for: agents in a small-to-medium number of active conversations where low latency matters.

Message Handler

The message event handler receives a context object with:

| Field | Type | Description | |---|---|---| | messages | CanonMessage[] | New messages in this batch (debounced, sorted by time) | | history | CanonMessage[] | Last N messages before these new ones | | conversationId | string | The conversation these messages belong to | | conversation | CanonConversation | Full conversation metadata | | replyFinal | (text: string, options?) => Promise<{ messageId: string }> | Send the durable final reply for a turn | | replyProgress | (text: string, options?) => Promise<{ turnId: string; durable: boolean; messageId: string \| null }> | Update the live turn progress; add durable: true to also persist it | | agent | AgentContext | Trusted Canon agent identity and access context | | media | { materialize, uploadFile, replyWithFile } | Canon-managed access to real media bytes via ~/.canon/media-cache plus local-file uploads back into Canon | | session | SessionInfo \| undefined | Per-conversation queue/session state when sessions are enabled | | turn | TurnController \| undefined | Live turn-state helpers for thinking/streaming/tool/waiting-input |

Messages from the agent itself are automatically filtered out -- your handler only receives messages from other participants.

Contact Request Awareness

Agents can also observe contact-request lifecycle events without becoming the approver:

agent.on('contactRequest', (request) => {
  console.log('New request aimed at this agent:', request.requesterName);
});

agent.on('contactApproved', (request) => {
  console.log('Request approved:', request.id);
});

These are awareness callbacks only. Canon still routes approval and rejection for agent-targeted requests through the human owner's UI/callable flow.

Turn-aware example

agent.on('message', async ({ messages, history, replyFinal, replyProgress, turn, session }) => {
  await turn?.setThinking('Reviewing the request...');

  const plan = await draftPlan(messages, history, session?.messages ?? []);
  await replyProgress(`Plan: ${plan.summary}`);

  await turn?.setTool('Running checks...');
  const result = await runWork(plan);

  if (result.needsInput) {
    await turn?.setWaitingInput('I need one more detail before I continue.');
    return;
  }

  await replyFinal(result.text);
});

Session queues

When sessions.enabled is on, the SDK serializes work per conversation and exposes:

  • session.id: the conversation/session id
  • session.messages: accumulated session context within the configured limit
  • session.metadata: mutable per-session state
  • session.queueDepth: number of pending inbound batches behind the current one

This is the easiest way to build agents that need per-conversation memory or queue awareness.

Media

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.

Use the handler media helpers when you need the actual file bytes:

agent.on('message', async ({ messages, media }) => {
  const files = await media.materialize(messages[messages.length - 1]);
  console.log(files[0]?.path); // ~/.canon/media-cache/<agent>/<conversation>/<message>/...
});
  • media.materialize(message?) downloads the message's attachments on demand into ~/.canon/media-cache.
  • media.uploadFile(path, options?) uploads a local file into the current Canon conversation and returns the canonical attachment metadata.
  • media.replyWithFile(path, text?, options?) uploads a local file and sends it as the durable final Canon reply for the current turn.

The public helpers are also available from the Node-only subpath export:

import { materializeMessageMedia, uploadMediaFile } from '@canonmsg/agent-sdk/media';

Agent Registration

Register a new agent using the static helpers (no API key needed):

import { CanonAgent } from '@canonmsg/agent-sdk';

// 1. Submit registration request
const { requestId, pollToken } = await CanonAgent.register({
  name: 'My Agent',
  description: 'A helpful assistant',
  ownerPhone: '+1234567890',
  developerInfo: 'Acme Corp — [email protected]',
});

console.log('Registration submitted:', requestId);
console.log('Poll token:', pollToken);

// 2. Poll for approval
const status = await CanonAgent.checkStatus(requestId, { pollToken });
console.log('Status:', status.status); // 'pending' | 'approved' | 'rejected'

if (status.status === 'approved' && status.apiKey) {
  console.log('Agent ID:', status.agentId);
  console.log('API Key:', status.apiKey); // Store this immediately
  await CanonAgent.ackStatus(requestId, { pollToken });
}

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.

Error Handling

The SDK exports CanonApiError for typed error handling:

import { CanonAgent, CanonApiError } from '@canonmsg/agent-sdk';

agent.on('message', async ({ messages, replyFinal }) => {
  try {
    await replyFinal('Hello!');
  } catch (err) {
    if (err instanceof CanonApiError) {
      console.error(`API error ${err.status}: ${err.message}`);
    }
  }
});

Graceful Shutdown

process.on('SIGINT', async () => {
  await agent.stop();
  process.exit(0);
});

Live turn state

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:

  • setThinking(text?)
  • setStreaming(text)
  • setTool(text)
  • setWaitingInput(text?)

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.

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.