@kanvas/openclaw-plugin 0.1.3 → 0.1.4

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.
@@ -1,4 +1,26 @@
1
+ import { readFile } from "node:fs/promises";
1
2
  import { postGraphQLMultipart } from "../../client/multipart.js";
3
+ const CONTENT_TYPES = {
4
+ ".pdf": "application/pdf",
5
+ ".jpg": "image/jpeg",
6
+ ".jpeg": "image/jpeg",
7
+ ".png": "image/png",
8
+ ".gif": "image/gif",
9
+ ".webp": "image/webp",
10
+ ".svg": "image/svg+xml",
11
+ ".doc": "application/msword",
12
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
13
+ ".xls": "application/vnd.ms-excel",
14
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
15
+ ".csv": "text/csv",
16
+ ".txt": "text/plain",
17
+ ".zip": "application/zip",
18
+ ".html": "text/html",
19
+ };
20
+ function guessContentType(fileName) {
21
+ const ext = fileName.slice(fileName.lastIndexOf(".")).toLowerCase();
22
+ return CONTENT_TYPES[ext] ?? "application/octet-stream";
23
+ }
2
24
  export class CrmService {
3
25
  client;
4
26
  constructor(client) {
@@ -162,6 +184,17 @@ export class CrmService {
162
184
  return this.client.query(mutation, { input });
163
185
  }
164
186
  async updateLead(id, input) {
187
+ // Auto-fetch branch_id and people_id if not provided (the API requires them)
188
+ if (!input.branch_id || !input.people_id) {
189
+ const current = await this.getLeadCore(id);
190
+ const lead = current.data?.leads?.data?.[0];
191
+ if (lead) {
192
+ if (!input.branch_id)
193
+ input.branch_id = lead.branch?.id;
194
+ if (!input.people_id)
195
+ input.people_id = lead.people?.id;
196
+ }
197
+ }
165
198
  const mutation = `
166
199
  mutation UpdateLead($id: ID!, $input: LeadUpdateInput!) {
167
200
  updateLead(id: $id, input: $input) {
@@ -180,6 +213,24 @@ export class CrmService {
180
213
  `;
181
214
  return this.client.query(mutation, { id, input });
182
215
  }
216
+ /** Lightweight lead fetch for getting required fields (branch_id, people_id). */
217
+ async getLeadCore(id) {
218
+ const query = `
219
+ query GetLeadCore($first: Int!, $where: QueryLeadsWhereWhereConditions) {
220
+ leads(first: $first, where: $where) {
221
+ data {
222
+ id
223
+ branch { id }
224
+ people { id }
225
+ }
226
+ }
227
+ }
228
+ `;
229
+ return this.client.query(query, {
230
+ first: 1,
231
+ where: [{ column: "ID", operator: "EQ", value: id }],
232
+ });
233
+ }
183
234
  async changeLeadOwner(input) {
184
235
  return this.updateLead(String(input.leadId), {
185
236
  branch_id: input.branch_id,
@@ -587,4 +638,263 @@ export class CrmService {
587
638
  `;
588
639
  return this.client.query(query, { first });
589
640
  }
641
+ async attachFileToLeadByUrl(leadId, fileUrl, fileName) {
642
+ const mutation = `
643
+ mutation CreateFilesystem($input: FilesystemInputUrl!) {
644
+ createFileSystem(input: $input) {
645
+ id
646
+ uuid
647
+ name
648
+ url
649
+ type
650
+ }
651
+ }
652
+ `;
653
+ const fileResult = await this.client.query(mutation, {
654
+ input: { url: fileUrl, name: fileName },
655
+ });
656
+ const fileUuid = fileResult.data?.createFileSystem?.uuid;
657
+ if (!fileUuid) {
658
+ return fileResult; // return the error
659
+ }
660
+ // Now attach via updateLead with files
661
+ return this.updateLead(leadId, {
662
+ files: [{ url: fileUrl, name: fileName }],
663
+ });
664
+ }
665
+ /**
666
+ * Resolve file content from base64, file path, or URL.
667
+ */
668
+ async resolveFileContent(source) {
669
+ if (source.base64) {
670
+ return Buffer.from(source.base64, "base64");
671
+ }
672
+ if (source.filePath) {
673
+ return readFile(source.filePath);
674
+ }
675
+ if (source.url) {
676
+ const response = await fetch(source.url);
677
+ if (!response.ok) {
678
+ throw new Error(`Failed to download file from ${source.url}: ${response.status}`);
679
+ }
680
+ return Buffer.from(await response.arrayBuffer());
681
+ }
682
+ throw new Error("One of base64, filePath, or url must be provided");
683
+ }
684
+ async uploadFileToLead(leadId, fileName, source, contentType) {
685
+ const buffer = await this.resolveFileContent(source);
686
+ return postGraphQLMultipart({
687
+ config: this.client.getConfig(),
688
+ query: `
689
+ mutation AttachFileToLead($id: ID!, $file: Upload!) {
690
+ attachFileToLead(id: $id, file: $file) {
691
+ id
692
+ uuid
693
+ title
694
+ files {
695
+ data {
696
+ id
697
+ uuid
698
+ name
699
+ url
700
+ type
701
+ }
702
+ }
703
+ }
704
+ }
705
+ `,
706
+ variables: { id: leadId, file: null },
707
+ files: [{
708
+ key: "variables.file",
709
+ fileName,
710
+ contentType: contentType ?? guessContentType(fileName),
711
+ content: buffer,
712
+ }],
713
+ });
714
+ }
715
+ async uploadFileToMessage(messageId, fileName, source, contentType) {
716
+ const buffer = await this.resolveFileContent(source);
717
+ return postGraphQLMultipart({
718
+ config: this.client.getConfig(),
719
+ query: `
720
+ mutation AttachFileToMessage($message_id: ID!, $file: Upload!) {
721
+ attachFileToMessage(message_id: $message_id, file: $file) {
722
+ id
723
+ uuid
724
+ message
725
+ files {
726
+ data {
727
+ id
728
+ uuid
729
+ name
730
+ url
731
+ type
732
+ }
733
+ }
734
+ }
735
+ }
736
+ `,
737
+ variables: { message_id: messageId, file: null },
738
+ files: [{
739
+ key: "variables.file",
740
+ fileName,
741
+ contentType: contentType ?? guessContentType(fileName),
742
+ content: buffer,
743
+ }],
744
+ });
745
+ }
746
+ async updatePeople(id, input) {
747
+ const mutation = `
748
+ mutation UpdatePeople($id: ID!, $input: PeopleInput!) {
749
+ updatePeople(id: $id, input: $input) {
750
+ id
751
+ uuid
752
+ firstname
753
+ middlename
754
+ lastname
755
+ dob
756
+ contacts {
757
+ id
758
+ value
759
+ type { id name }
760
+ }
761
+ address {
762
+ id
763
+ address
764
+ city
765
+ state
766
+ zip
767
+ country
768
+ }
769
+ organizations { name }
770
+ custom_fields { name value }
771
+ tags { id name }
772
+ updated_at
773
+ }
774
+ }
775
+ `;
776
+ return this.client.query(mutation, { id, input });
777
+ }
778
+ async searchPeople(search, first = 10) {
779
+ const query = `
780
+ query SearchPeople($first: Int, $search: String) {
781
+ peoples(first: $first, search: $search) {
782
+ data {
783
+ id
784
+ uuid
785
+ firstname
786
+ lastname
787
+ contacts {
788
+ id
789
+ value
790
+ type { id name }
791
+ }
792
+ organizations { name }
793
+ created_at
794
+ }
795
+ }
796
+ }
797
+ `;
798
+ return this.client.query(query, { first, search });
799
+ }
800
+ async listPeopleRelationships(first = 50) {
801
+ const query = `
802
+ query PeopleRelationships($first: Int) {
803
+ peopleRelationships(first: $first) {
804
+ data {
805
+ id
806
+ name
807
+ description
808
+ }
809
+ }
810
+ }
811
+ `;
812
+ return this.client.query(query, { first });
813
+ }
814
+ async listContactTypes(first = 50) {
815
+ const query = `
816
+ query ContactTypes($first: Int) {
817
+ contactTypes(first: $first) {
818
+ data {
819
+ id
820
+ name
821
+ }
822
+ }
823
+ }
824
+ `;
825
+ return this.client.query(query, { first });
826
+ }
827
+ async createFollowUpEvent(input) {
828
+ const mutation = `
829
+ mutation CreateEvent($input: EventInput!) {
830
+ createEvent(input: $input) {
831
+ id
832
+ uuid
833
+ name
834
+ description
835
+ created_at
836
+ versions {
837
+ data {
838
+ id
839
+ start_at
840
+ end_at
841
+ dates {
842
+ id
843
+ date
844
+ start_time
845
+ end_time
846
+ }
847
+ }
848
+ }
849
+ }
850
+ }
851
+ `;
852
+ const eventInput = {
853
+ name: input.name,
854
+ description: input.description,
855
+ dates: [{
856
+ date: input.date,
857
+ start_time: input.start_time,
858
+ end_time: input.end_time,
859
+ }],
860
+ };
861
+ if (input.lead_id) {
862
+ eventInput.resources = [{
863
+ resources_id: String(input.lead_id),
864
+ resources_type: "lead",
865
+ }];
866
+ }
867
+ return this.client.query(mutation, { input: eventInput });
868
+ }
869
+ async listEvents(first = 25, where) {
870
+ const query = `
871
+ query Events($first: Int, $where: QueryEventsWhereWhereConditions) {
872
+ events(first: $first, where: $where, orderBy: [{ column: CREATED_AT, order: DESC }]) {
873
+ data {
874
+ id
875
+ uuid
876
+ name
877
+ description
878
+ created_at
879
+ type { name }
880
+ eventStatus { name }
881
+ versions {
882
+ data {
883
+ id
884
+ start_at
885
+ end_at
886
+ dates {
887
+ id
888
+ date
889
+ start_time
890
+ end_time
891
+ }
892
+ }
893
+ }
894
+ }
895
+ }
896
+ }
897
+ `;
898
+ return this.client.query(query, { first, where });
899
+ }
590
900
  }
package/dist/index.js CHANGED
@@ -1,4 +1,3 @@
1
- import { definePluginEntry } from "openclaw/plugin-sdk/core";
2
1
  import { Type } from "@sinclair/typebox";
3
2
  import { KanvasClient } from "./client/kanvas-client.js";
4
3
  import { CrmService } from "./domains/crm/index.js";
@@ -62,10 +61,11 @@ function createAuthGuard(client, config, logger) {
62
61
  return authPromise;
63
62
  };
64
63
  }
65
- export default definePluginEntry({
64
+ export default {
66
65
  id: "kanvas",
67
66
  name: "Kanvas CRM",
68
67
  description: "Connects agents to Kanvas — your company's nervous system for CRM, inventory, orders, and messaging.",
68
+ configSchema: { type: "object" },
69
69
  register(api) {
70
70
  const config = resolveConfig(api.pluginConfig);
71
71
  const client = new KanvasClient(config);
@@ -103,9 +103,9 @@ export default definePluginEntry({
103
103
  await runSetup();
104
104
  });
105
105
  }, { commands: ["setup"] });
106
- api.logger.info("Kanvas plugin registered — 41 tools loaded");
106
+ api.logger.info("Kanvas plugin registered — 50 tools loaded");
107
107
  },
108
- });
108
+ };
109
109
  const KANVAS_SYSTEM_CONTEXT = `
110
110
  ## Kanvas Plugin
111
111
 
@@ -124,6 +124,16 @@ Use when the user asks about leads, prospects, sales pipeline, contacts, or foll
124
124
  - \`kanvas_add_lead_participant\` / \`kanvas_remove_lead_participant\` → manage related people
125
125
  - \`kanvas_follow_lead\` / \`kanvas_unfollow_lead\` → subscribe to lead updates
126
126
  - \`kanvas_delete_lead\` / \`kanvas_restore_lead\` → soft-delete and restore
127
+ - \`kanvas_attach_file_to_lead_by_url\` → attach a file to a lead from a public URL
128
+ - \`kanvas_upload_file_to_lead\` → upload a file directly to a lead (base64-encoded content — for PDFs, images, generated docs)
129
+ - \`kanvas_upload_file_to_message\` → upload a file to a message (base64-encoded)
130
+
131
+ **People / Contacts**
132
+ Use when the user asks about updating contact info, adding phone numbers or emails.
133
+ - \`kanvas_search_people\` → search contacts by name, email, or phone
134
+ - \`kanvas_update_people\` → update a person's name, phone, email, address, tags (call kanvas_list_contact_types first for contact type IDs)
135
+ - \`kanvas_list_contact_types\` → get valid contact type IDs (email, phone, etc.)
136
+ - \`kanvas_list_people_relationships\` → list relationship types for lead participants
127
137
 
128
138
  **CRM Support Data** — call these first when creating/updating leads to get valid IDs:
129
139
  - \`kanvas_list_pipelines\` → pipelines and their stages (needed for pipeline_stage_id)
@@ -167,6 +177,12 @@ Use when the user asks about orders, purchases, or sales.
167
177
  - \`kanvas_search_orders\` → find orders by number or keyword
168
178
  - \`kanvas_get_order\` → full order detail with items, customer, status
169
179
 
180
+ **Follow-ups & Reminders**
181
+ ALWAYS schedule follow-ups in Kanvas so the human team can see them — NEVER store them only in local memory.
182
+ - \`kanvas_create_follow_up\` → create a calendar event for a future action (e.g. "Call Jane re: proposal"). Optionally link to a lead. Requires: name, date, start_time, end_time.
183
+ - \`kanvas_list_events\` → list scheduled events/follow-ups
184
+ - For structured follow-up data (tracking status, priority, custom fields), use \`kanvas_create_message\` with verb "follow_up" and a JSON payload containing { due_date, lead_id, action, status, priority }.
185
+
170
186
  **Diagnostics**
171
187
  - \`kanvas_test_connection\` → verify the API is reachable
172
188
 
@@ -176,4 +192,7 @@ Use when the user asks about orders, purchases, or sales.
176
192
  - Messages use \`message_verb\` to define their type (e.g. "comment", "note", "sms"). New verbs are auto-created.
177
193
  - The \`message\` field in social messages accepts arbitrary JSON — use it to store any structured data.
178
194
  - Filtering uses \`where: [{ column, operator, value }]\` format. Common operators: "EQ", "LIKE", "IN".
195
+ - **Follow-ups and reminders MUST go in Kanvas** (events or messages), not local memory. The human team needs to see them in the dashboard.
196
+ - When updating a lead, you don't need to provide branch_id or people_id — they are auto-fetched.
197
+ - To add contacts to a person, call \`kanvas_list_contact_types\` first, then pass the contacts array to \`kanvas_update_people\`.
179
198
  `.trim();
package/dist/tools/crm.js CHANGED
@@ -61,12 +61,12 @@ export function registerCrmTools(api, service, ensureAuth) {
61
61
  api.registerTool({
62
62
  name: "kanvas_update_lead",
63
63
  label: "Update Lead",
64
- description: "Update an existing lead's fields.",
64
+ description: "Update an existing lead's fields. branch_id and people_id are auto-fetched if not provided.",
65
65
  parameters: Type.Object({
66
66
  id: Type.String({ description: "Lead ID" }),
67
67
  input: Type.Object({
68
- branch_id: Type.Union([Type.String(), Type.Number()]),
69
- people_id: Type.Union([Type.String(), Type.Number()]),
68
+ branch_id: Type.Optional(Type.Union([Type.String(), Type.Number()], { description: "Auto-fetched if omitted" })),
69
+ people_id: Type.Optional(Type.Union([Type.String(), Type.Number()], { description: "Auto-fetched if omitted" })),
70
70
  title: Type.Optional(Type.String()),
71
71
  leads_owner_id: Type.Optional(Type.Union([Type.String(), Type.Number()])),
72
72
  organization_id: Type.Optional(Type.Union([Type.String(), Type.Number()])),
@@ -340,4 +340,159 @@ export function registerCrmTools(api, service, ensureAuth) {
340
340
  return toolResult(await service.listLeadTypes(params.first));
341
341
  },
342
342
  });
343
+ api.registerTool({
344
+ name: "kanvas_attach_file_to_lead_by_url",
345
+ label: "Attach File to Lead by URL",
346
+ description: "Attach a file (PDF, image, document) to a lead using a public URL. " +
347
+ "The file is downloaded by the server and attached to the lead.",
348
+ parameters: Type.Object({
349
+ leadId: Type.String({ description: "Lead ID" }),
350
+ fileUrl: Type.String({ description: "Public URL of the file to attach" }),
351
+ fileName: Type.String({ description: "File name (e.g. proposal.pdf, photo.jpg)" }),
352
+ }),
353
+ async execute(_id, params) {
354
+ await ensureAuth();
355
+ return toolResult(await service.attachFileToLeadByUrl(params.leadId, params.fileUrl, params.fileName));
356
+ },
357
+ });
358
+ api.registerTool({
359
+ name: "kanvas_upload_file_to_lead",
360
+ label: "Upload File to Lead",
361
+ description: "Upload a file to a lead. Provide the content as base64, a local file path, or a URL to download from. " +
362
+ "Exactly one of base64, filePath, or url must be provided.",
363
+ parameters: Type.Object({
364
+ leadId: Type.String({ description: "Lead ID" }),
365
+ fileName: Type.String({ description: "File name with extension (e.g. proposal.pdf, photo.jpg)" }),
366
+ base64: Type.Optional(Type.String({ description: "Base64-encoded file content" })),
367
+ filePath: Type.Optional(Type.String({ description: "Absolute path to a local file" })),
368
+ url: Type.Optional(Type.String({ description: "URL to download the file from" })),
369
+ contentType: Type.Optional(Type.String({ description: "MIME type (auto-detected from extension if omitted)" })),
370
+ }),
371
+ async execute(_id, params) {
372
+ await ensureAuth();
373
+ const { leadId, fileName, contentType, ...source } = params;
374
+ return toolResult(await service.uploadFileToLead(leadId, fileName, source, contentType));
375
+ },
376
+ });
377
+ api.registerTool({
378
+ name: "kanvas_upload_file_to_message",
379
+ label: "Upload File to Message",
380
+ description: "Upload a file to a message. Provide the content as base64, a local file path, or a URL to download from.",
381
+ parameters: Type.Object({
382
+ messageId: Type.String({ description: "Message ID" }),
383
+ fileName: Type.String({ description: "File name with extension" }),
384
+ base64: Type.Optional(Type.String({ description: "Base64-encoded file content" })),
385
+ filePath: Type.Optional(Type.String({ description: "Absolute path to a local file" })),
386
+ url: Type.Optional(Type.String({ description: "URL to download the file from" })),
387
+ contentType: Type.Optional(Type.String({ description: "MIME type (auto-detected if omitted)" })),
388
+ }),
389
+ async execute(_id, params) {
390
+ await ensureAuth();
391
+ const { messageId, fileName, contentType, ...source } = params;
392
+ return toolResult(await service.uploadFileToMessage(messageId, fileName, source, contentType));
393
+ },
394
+ });
395
+ // --- People / Contacts ---
396
+ api.registerTool({
397
+ name: "kanvas_search_people",
398
+ label: "Search People",
399
+ description: "Search contacts/people by name, email, or phone.",
400
+ parameters: Type.Object({
401
+ search: Type.String({ description: "Search keyword" }),
402
+ first: Type.Optional(Type.Number({ description: "Max results (default 10)" })),
403
+ }),
404
+ async execute(_id, params) {
405
+ await ensureAuth();
406
+ return toolResult(await service.searchPeople(params.search, params.first));
407
+ },
408
+ });
409
+ api.registerTool({
410
+ name: "kanvas_update_people",
411
+ label: "Update People/Contact",
412
+ description: "Update a person's profile — name, phone, email, address, tags, custom fields. " +
413
+ "To add contacts (phone/email), pass contacts array with value and contacts_types_id. " +
414
+ "Call kanvas_list_contact_types first to get valid contact type IDs.",
415
+ parameters: Type.Object({
416
+ id: Type.String({ description: "People ID" }),
417
+ input: Type.Object({
418
+ firstname: Type.Optional(Type.String()),
419
+ middlename: Type.Optional(Type.String()),
420
+ lastname: Type.Optional(Type.String()),
421
+ dob: Type.Optional(Type.String({ description: "Date of birth (YYYY-MM-DD)" })),
422
+ organization: Type.Optional(Type.String()),
423
+ contacts: Type.Optional(Type.Array(Type.Object({
424
+ value: Type.String({ description: "Phone number, email, etc." }),
425
+ contacts_types_id: Type.Union([Type.String(), Type.Number()], { description: "Contact type ID (call kanvas_list_contact_types)" }),
426
+ weight: Type.Optional(Type.Number()),
427
+ is_opt_out: Type.Optional(Type.Boolean()),
428
+ }))),
429
+ tags: Type.Optional(Type.Array(Type.Object({ name: Type.String() }))),
430
+ custom_fields: Type.Optional(Type.Array(Type.Record(Type.String(), Type.Unknown()))),
431
+ }),
432
+ }),
433
+ async execute(_id, params) {
434
+ await ensureAuth();
435
+ return toolResult(await service.updatePeople(params.id, params.input));
436
+ },
437
+ });
438
+ api.registerTool({
439
+ name: "kanvas_list_people_relationships",
440
+ label: "List People Relationships",
441
+ description: "List available relationship types for lead participants (e.g. Architect, Client, Consultant).",
442
+ parameters: Type.Object({
443
+ first: Type.Optional(Type.Number({ description: "Max results (default 50)" })),
444
+ }),
445
+ async execute(_id, params) {
446
+ await ensureAuth();
447
+ return toolResult(await service.listPeopleRelationships(params.first));
448
+ },
449
+ });
450
+ api.registerTool({
451
+ name: "kanvas_list_contact_types",
452
+ label: "List Contact Types",
453
+ description: "List available contact types (email, phone, etc.) needed for adding contacts to people.",
454
+ parameters: Type.Object({
455
+ first: Type.Optional(Type.Number({ description: "Max results (default 50)" })),
456
+ }),
457
+ async execute(_id, params) {
458
+ await ensureAuth();
459
+ return toolResult(await service.listContactTypes(params.first));
460
+ },
461
+ });
462
+ // --- Events / Follow-ups ---
463
+ api.registerTool({
464
+ name: "kanvas_create_follow_up",
465
+ label: "Create Follow-up",
466
+ description: "Schedule a follow-up reminder as a calendar event. Optionally link to a lead. " +
467
+ "Use this for any action that needs to happen on a future date — the team can see it in the Kanvas dashboard.",
468
+ parameters: Type.Object({
469
+ name: Type.String({ description: 'Follow-up name (e.g. "Call Jane Doe re: proposal")' }),
470
+ description: Type.Optional(Type.String({ description: "Details/notes about the follow-up" })),
471
+ date: Type.String({ description: "Date (YYYY-MM-DD)" }),
472
+ start_time: Type.String({ description: "Start time (HH:MM, 24h format)" }),
473
+ end_time: Type.String({ description: "End time (HH:MM, 24h format)" }),
474
+ lead_id: Type.Union([Type.String(), Type.Number()], { description: "Lead ID to link this follow-up to" }),
475
+ }),
476
+ async execute(_id, params) {
477
+ await ensureAuth();
478
+ return toolResult(await service.createFollowUpEvent(params));
479
+ },
480
+ });
481
+ api.registerTool({
482
+ name: "kanvas_list_events",
483
+ label: "List Events",
484
+ description: "List scheduled events/follow-ups with optional filtering.",
485
+ parameters: Type.Object({
486
+ first: Type.Optional(Type.Number({ description: "Max results (default 25)" })),
487
+ where: Type.Optional(Type.Array(Type.Object({
488
+ column: Type.String({ description: 'e.g. "ID", "NAME"' }),
489
+ operator: Type.String({ description: 'e.g. "EQ", "LIKE"' }),
490
+ value: Type.Unknown(),
491
+ }))),
492
+ }),
493
+ async execute(_id, params) {
494
+ await ensureAuth();
495
+ return toolResult(await service.listEvents(params.first, params.where));
496
+ },
497
+ });
343
498
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kanvas/openclaw-plugin",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Connects agents to Kanvas — your company's nervous system for CRM, inventory, orders, and messaging.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -10,6 +10,7 @@
10
10
  "keywords": ["openclaw", "kanvas", "crm", "plugin", "ai-agent"],
11
11
  "type": "module",
12
12
  "openclaw": {
13
+ "id": "kanvas",
13
14
  "extensions": [
14
15
  "./dist/index.js"
15
16
  ]