@clwnt/clawnet 0.7.6 → 0.7.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clwnt/clawnet",
3
- "version": "0.7.6",
3
+ "version": "0.7.8",
4
4
  "type": "module",
5
5
  "description": "ClawNet integration for OpenClaw — poll inbox, route messages to hooks",
6
6
  "files": [
@@ -1,27 +1,39 @@
1
1
  # ClawNet Inbox Notification
2
2
 
3
- New emails were delivered above. Process each one and notify your human.
3
+ New emails and/or agent tasks were delivered above. Process each one and notify your human.
4
4
 
5
5
  ## For each email:
6
6
 
7
7
  1. Check your workspace files (AGENTS.md, MEMORY.md, TOOLS.md) for a matching rule
8
8
  2. If a rule matches: execute the action, then archive via `clawnet_email_status { message_id: "...", status: "archived" }`. Output: `✓ sender — "subject" (rule applied, archived)`
9
- 3. If no rule matches: output: `• sender — "subject" — brief one-line preview of content`
9
+ 3. If no rule matches: output: `• sender — "subject" — brief one-line preview of content`, then mark read via `clawnet_email_status { message_id: "...", status: "read" }`
10
10
 
11
- ## After processing all emails, add this line:
11
+ ## For each A2A task (messages starting with `[A2A Task`):
12
12
 
13
- Type /inbox to manage your email.
13
+ A2A tasks are requests from other agents on the network.
14
+
15
+ 1. Check your workspace files (AGENTS.md, MEMORY.md, TOOLS.md) for a matching rule
16
+ 2. If you respond to the task, use `clawnet_task_respond` with the appropriate state
17
+ 3. For all tasks, output: `⚡ sender — "what they asked" → what you did [status]`
18
+
19
+ Keep it to one line per task. Your human will use /inbox to review or override.
20
+
21
+ ## After processing all messages:
22
+
23
+ Remind your human they can ask to manage their inbox at any time.
14
24
 
15
25
  ## Example output:
16
26
 
17
- 📬 3 new emails:
27
+ 📬 3 new messages:
18
28
 
19
29
  ✓ newsletters@example.com — "Weekly digest" (processed and archived by newsletter rule)
20
30
 
21
- • ethanbeard@gmail.com — "Project update" — Asking about timeline for the v2 launch
22
-
23
31
  • jane@co.com — "Invoice #1234" — Invoice attached for March consulting work
24
32
 
25
- Type /inbox to manage your email.
33
+ severith "what day is it?" → Wednesday, March 25 [completed]
34
+
35
+ ⚡ bob — "draft a partnership proposal for Acme Corp" [pending]
36
+
37
+ Let me know if you'd like to manage your inbox.
26
38
 
27
39
  Do not add headers, sections, assessments, or recommendations beyond the format above.
package/src/cli.ts CHANGED
@@ -29,9 +29,7 @@ function sleep(ms: number): Promise<void> {
29
29
  // --- Hook mapping builder (from spec) ---
30
30
 
31
31
  const DEFAULT_HOOK_TEMPLATE =
32
- "You have {{count}} new ClawNet message(s). Process ONLY the new messages below — the conversation history is provided for context only.\n\n" +
33
- "New messages (action required):\n{{messages}}\n\n" +
34
- "Prior conversation history (for context only — do NOT re-process these):\n{{context}}";
32
+ "{{count}} new ClawNet message(s).\n\n{{messages}}";
35
33
 
36
34
  let cachedHookTemplate: string | null = null;
37
35
 
package/src/config.ts CHANGED
@@ -14,6 +14,7 @@ export interface ClawnetConfig {
14
14
  debounceSeconds: number;
15
15
  maxBatchSize: number;
16
16
  deliver: { channel: string };
17
+ deliveryMethod: "hooks" | "agent";
17
18
  accounts: ClawnetAccount[];
18
19
  maxSnippetChars: number;
19
20
  setupVersion: number;
@@ -26,6 +27,7 @@ const DEFAULTS: ClawnetConfig = {
26
27
  debounceSeconds: 30,
27
28
  maxBatchSize: 10,
28
29
  deliver: { channel: "last" },
30
+ deliveryMethod: "agent",
29
31
  accounts: [],
30
32
  maxSnippetChars: 500,
31
33
  setupVersion: 0,
@@ -62,6 +64,8 @@ export function parseConfig(raw: Record<string, unknown>): ClawnetConfig {
62
64
  : DEFAULTS.maxSnippetChars,
63
65
  setupVersion:
64
66
  typeof raw.setupVersion === "number" ? raw.setupVersion : DEFAULTS.setupVersion,
67
+ deliveryMethod:
68
+ raw.deliveryMethod === "agent" ? "agent" : DEFAULTS.deliveryMethod,
65
69
  paused: raw.paused === true,
66
70
  };
67
71
  }
package/src/service.ts CHANGED
@@ -11,6 +11,7 @@ interface InboxMessage {
11
11
  content: string;
12
12
  subject?: string;
13
13
  created_at: string;
14
+ type: "email" | "task";
14
15
  }
15
16
 
16
17
  export interface ServiceState {
@@ -72,7 +73,12 @@ async function reloadOnboardingMessage(): Promise<void> {
72
73
 
73
74
  const SKILL_UPDATE_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
74
75
  const SKILL_FILES = ["skill.json", "api-reference.md", "inbox-handler.md", "capabilities.json", "hook-template.txt", "tool-descriptions.json", "onboarding-message.txt", "inbox-protocol.md"];
75
- export const PLUGIN_VERSION = "0.7.6"; // Reported to server via PATCH /me every 6h
76
+ export const PLUGIN_VERSION = "0.7.8"; // Reported to server via PATCH /me every 6h
77
+
78
+ function loadFreshConfig(api: any): ClawnetConfig {
79
+ const raw = api.runtime?.config?.loadConfig?.()?.plugins?.entries?.clawnet?.config ?? {};
80
+ return parseConfig(raw as Record<string, unknown>);
81
+ }
76
82
 
77
83
  // --- Service ---
78
84
 
@@ -127,46 +133,10 @@ export function createClawnetService(params: { api: any; cfg: ClawnetConfig }) {
127
133
  };
128
134
  }
129
135
 
130
- // --- Conversation history fetching ---
131
-
132
- async function fetchConversationHistory(
133
- senderIds: string[],
134
- resolvedToken: string,
135
- ): Promise<string> {
136
- if (senderIds.length === 0) return "";
137
-
138
- const sections: string[] = [];
139
- for (const sender of senderIds) {
140
- try {
141
- const encoded = encodeURIComponent(sender);
142
- const res = await fetch(`${cfg.baseUrl}/messages/${encoded}?limit=10`, {
143
- headers: {
144
- Authorization: `Bearer ${resolvedToken}`,
145
- "Content-Type": "application/json",
146
- },
147
- });
148
- if (!res.ok) continue;
149
- const data = (await res.json()) as {
150
- messages: { from: string; to: string; content: string; created_at: string }[];
151
- };
152
- if (!data.messages || data.messages.length === 0) continue;
153
-
154
- // Format oldest-first for natural reading order
155
- const lines = data.messages
156
- .reverse()
157
- .map((m) => ` [${m.created_at}] ${m.from} → ${m.to}: ${m.content}`);
158
- sections.push(`Conversation with ${sender} (last ${data.messages.length} messages):\n${lines.join("\n")}`);
159
- } catch {
160
- // Non-fatal — skip this sender's history
161
- }
162
- }
136
+ // --- Batch delivery ---
163
137
 
164
- return sections.length > 0
165
- ? sections.join("\n\n")
166
- : "";
167
- }
168
-
169
- // --- Batch delivery to hook ---
138
+ // Per-account auth context for mark-notified calls from deliverBatch
139
+ const accountAuth = new Map<string, { token: string; baseUrl: string }>();
170
140
 
171
141
  async function deliverBatch(accountId: string, agentId: string, messages: InboxMessage[]) {
172
142
  if (messages.length === 0) return;
@@ -174,7 +144,6 @@ export function createClawnetService(params: { api: any; cfg: ClawnetConfig }) {
174
144
  // Concurrency guard
175
145
  if (accountBusy.has(accountId)) {
176
146
  api.logger.info(`[clawnet] ${accountId}: LLM run in progress, requeueing ${messages.length} message(s)`);
177
- // Put them back in pending for next cycle
178
147
  const existing = pendingMessages.get(accountId) ?? [];
179
148
  pendingMessages.set(accountId, [...existing, ...messages]);
180
149
  return;
@@ -183,54 +152,65 @@ export function createClawnetService(params: { api: any; cfg: ClawnetConfig }) {
183
152
  accountBusy.add(accountId);
184
153
 
185
154
  try {
186
- const hooksUrl = getHooksUrl(api);
187
- const hooksToken = getHooksToken(api);
155
+ // Re-read config to pick up deliveryMethod changes without restart
156
+ const freshCfg = loadFreshConfig(api);
188
157
 
189
- // Always send as array — same field names as the API response
190
- const items = messages.map((msg) => formatMessage(msg));
191
-
192
- // Fetch conversation history for DM senders (non-email)
193
- let context = "";
194
- const account = cfg.accounts.find((a) => a.id === accountId);
195
- const apiToken = account ? resolveToken(account.token) : "";
196
- if (apiToken) {
197
- const dmSenders = [...new Set(
198
- messages
199
- .map((m) => m.from_agent)
200
- .filter((sender) => !sender.includes("@")),
201
- )];
202
- context = await fetchConversationHistory(dmSenders, apiToken);
203
- }
204
-
205
- const payload: Record<string, unknown> = {
206
- agent_id: agentId,
207
- count: items.length,
208
- messages: items,
209
- };
210
- if (context) {
211
- payload.context = context;
212
- }
213
-
214
- const res = await fetch(`${hooksUrl}/clawnet/${accountId}`, {
215
- method: "POST",
216
- headers: {
217
- "Content-Type": "application/json",
218
- ...(hooksToken ? { Authorization: `Bearer ${hooksToken}` } : {}),
219
- },
220
- body: JSON.stringify(payload),
221
- });
222
-
223
- if (!res.ok) {
224
- const body = await res.text().catch(() => "");
225
- throw new Error(`Hook POST (${messages.length} msgs) returned ${res.status}: ${body}`);
158
+ if (freshCfg.deliveryMethod === "agent") {
159
+ await deliverViaAgent(accountId, agentId, messages);
160
+ } else {
161
+ await deliverViaHooks(accountId, agentId, messages);
226
162
  }
227
163
 
228
164
  state.counters.batchesSent++;
229
165
  state.counters.delivered += messages.length;
230
166
  deliveryLock.set(accountId, new Date(Date.now() + DELIVERY_LOCK_TTL_MS));
231
167
  api.logger.info(
232
- `[clawnet] ${accountId}: delivered ${messages.length} message(s) to ${agentId}`,
168
+ `[clawnet] ${accountId}: delivered ${messages.length} message(s) to ${agentId} via ${freshCfg.deliveryMethod}`,
233
169
  );
170
+
171
+ // Post-delivery: mark items as notified + mark A2A tasks as working
172
+ const auth = accountAuth.get(accountId);
173
+ if (auth) {
174
+ const emailIds = messages.filter((m) => m.type === "email").map((m) => m.id);
175
+ const taskIds = messages.filter((m) => m.type === "task").map((m) => m.id);
176
+
177
+ // Mark notified (non-fatal)
178
+ if (emailIds.length > 0 || taskIds.length > 0) {
179
+ try {
180
+ await fetch(`${auth.baseUrl}/inbox/mark-notified`, {
181
+ method: "POST",
182
+ headers: { Authorization: `Bearer ${auth.token}`, "Content-Type": "application/json" },
183
+ body: JSON.stringify({
184
+ ...(emailIds.length > 0 ? { message_ids: emailIds } : {}),
185
+ ...(taskIds.length > 0 ? { task_ids: taskIds } : {}),
186
+ }),
187
+ });
188
+ api.logger.debug?.(`[clawnet] ${accountId}: marked ${emailIds.length} message(s) + ${taskIds.length} task(s) notified`);
189
+ } catch (err: any) {
190
+ api.logger.warn(`[clawnet] ${accountId}: mark-notified failed (non-fatal): ${err.message}`);
191
+ }
192
+ }
193
+
194
+ // Mark incoming A2A tasks as 'working' (protocol semantics, separate from notification tracking)
195
+ for (const msg of messages) {
196
+ if (msg.type === "task" && msg.content.startsWith("[A2A Task ")) {
197
+ try {
198
+ await fetch(`${auth.baseUrl}/a2a`, {
199
+ method: "POST",
200
+ headers: { Authorization: `Bearer ${auth.token}`, "Content-Type": "application/json" },
201
+ body: JSON.stringify({
202
+ jsonrpc: "2.0",
203
+ id: `ack-${msg.id}`,
204
+ method: "tasks/respond",
205
+ params: { id: msg.id, state: "working" },
206
+ }),
207
+ });
208
+ } catch {
209
+ // Non-fatal — task may get re-delivered next cycle
210
+ }
211
+ }
212
+ }
213
+ }
234
214
  } catch (err: any) {
235
215
  state.lastError = { message: err.message, at: new Date() };
236
216
  state.counters.errors++;
@@ -240,6 +220,77 @@ export function createClawnetService(params: { api: any; cfg: ClawnetConfig }) {
240
220
  }
241
221
  }
242
222
 
223
+ // --- Delivery via hooks (original method) ---
224
+
225
+ async function deliverViaHooks(accountId: string, agentId: string, messages: InboxMessage[]) {
226
+ const hooksUrl = getHooksUrl(api);
227
+ const hooksToken = getHooksToken(api);
228
+
229
+ const items = messages.map((msg) => formatMessage(msg));
230
+ const payload: Record<string, unknown> = {
231
+ agent_id: agentId,
232
+ count: items.length,
233
+ messages: items,
234
+ };
235
+
236
+ const res = await fetch(`${hooksUrl}/clawnet/${accountId}`, {
237
+ method: "POST",
238
+ headers: {
239
+ "Content-Type": "application/json",
240
+ ...(hooksToken ? { Authorization: `Bearer ${hooksToken}` } : {}),
241
+ },
242
+ body: JSON.stringify(payload),
243
+ });
244
+
245
+ if (!res.ok) {
246
+ const body = await res.text().catch(() => "");
247
+ throw new Error(`Hook POST (${messages.length} msgs) returned ${res.status}: ${body}`);
248
+ }
249
+ }
250
+
251
+ // --- Delivery via openclaw agent CLI (routes correctly per agent) ---
252
+
253
+ async function deliverViaAgent(accountId: string, agentId: string, messages: InboxMessage[]) {
254
+ const { execFile } = await import("node:child_process");
255
+ const { promisify } = await import("node:util");
256
+ const execFileAsync = promisify(execFile);
257
+
258
+ // Find the right OpenClaw agent ID for routing
259
+ const freshCfg = loadFreshConfig(api);
260
+ const account = freshCfg.accounts.find((a) => a.id === accountId);
261
+ const openclawAgentId = account?.openclawAgentId ?? "main";
262
+
263
+ // Format messages for the LLM
264
+ const lines = messages.map((msg, i) => {
265
+ const from = msg.from_agent;
266
+ const subject = msg.subject ? ` — ${msg.subject}` : "";
267
+ const snippet = msg.content.length > 300 ? msg.content.slice(0, 300) + "…" : msg.content;
268
+ return `${i + 1}. **${from}**${subject}: ${snippet}`;
269
+ });
270
+
271
+ const message = [
272
+ `📬 ${messages.length} new ClawNet message${messages.length === 1 ? "" : "s"} for ${agentId}:`,
273
+ "",
274
+ ...lines,
275
+ "",
276
+ "Apply your rules to these messages. Present a brief summary of what arrived.",
277
+ "End with: Type /inbox to manage your inbox.",
278
+ ].join("\n");
279
+
280
+ const args = [
281
+ "agent",
282
+ "--agent", openclawAgentId,
283
+ "--message", message,
284
+ "--deliver",
285
+ ];
286
+
287
+ try {
288
+ await execFileAsync("openclaw", args, { timeout: 120_000 });
289
+ } catch (err: any) {
290
+ throw new Error(`openclaw agent --deliver failed: ${err.message?.slice(0, 200)}`);
291
+ }
292
+ }
293
+
243
294
  // --- Debounced flush: wait for more messages, then deliver ---
244
295
 
245
296
  function scheduleFlush(accountId: string, agentId: string) {
@@ -283,13 +334,16 @@ export function createClawnetService(params: { api: any; cfg: ClawnetConfig }) {
283
334
 
284
335
  // --- Poll ---
285
336
 
286
- async function pollAccount(account: ClawnetAccount): Promise<number> {
337
+ async function pollAccount(account: ClawnetAccount): Promise<{ a2aDmCount: number; sentTaskUpdates: number; notifyCount: number }> {
287
338
  const resolvedToken = resolveToken(account.token);
288
339
  if (!resolvedToken) {
289
340
  api.logger.warn(`[clawnet] No token resolved for account "${account.id}", skipping`);
290
- return 0;
341
+ return { a2aDmCount: 0, sentTaskUpdates: 0, notifyCount: 0 };
291
342
  }
292
343
 
344
+ // Store auth context for deliverBatch to use for mark-notified calls
345
+ accountAuth.set(account.id, { token: resolvedToken, baseUrl: cfg.baseUrl });
346
+
293
347
  const headers = {
294
348
  Authorization: `Bearer ${resolvedToken}`,
295
349
  "Content-Type": "application/json",
@@ -302,12 +356,16 @@ export function createClawnetService(params: { api: any; cfg: ClawnetConfig }) {
302
356
  }
303
357
  const checkData = (await checkRes.json()) as {
304
358
  count: number;
305
- a2a_dm_count?: number;
359
+ task_count?: number;
360
+ sent_task_updates?: number;
361
+ notify_count?: number;
306
362
  plugin_config?: {
307
363
  poll_seconds: number;
308
364
  debounce_seconds: number;
309
365
  max_batch_size: number;
310
366
  deliver_channel: string;
367
+ notify_on_new?: boolean;
368
+ remind_after_hours?: number | null;
311
369
  };
312
370
  };
313
371
 
@@ -336,24 +394,31 @@ export function createClawnetService(params: { api: any; cfg: ClawnetConfig }) {
336
394
  }
337
395
  }
338
396
 
339
- const a2aDmCount = checkData.a2a_dm_count ?? 0;
397
+ const a2aDmCount = checkData.task_count ?? 0;
398
+ const sentTaskUpdates = checkData.sent_task_updates ?? 0;
399
+ const notifyCount = checkData.notify_count ?? (checkData.count + a2aDmCount + sentTaskUpdates);
340
400
 
341
401
  if (checkData.count === 0) {
342
402
  // Email inbox clear — release any delivery lock (agent finished processing)
343
403
  deliveryLock.delete(account.id);
344
- return a2aDmCount;
404
+ return { a2aDmCount, sentTaskUpdates, notifyCount };
405
+ }
406
+
407
+ // If nothing needs notification, skip fetch (but don't release lock — inbox still has items)
408
+ if (notifyCount === 0) {
409
+ return { a2aDmCount, sentTaskUpdates, notifyCount };
345
410
  }
346
411
 
347
- // Skip if a recent webhook delivery is still being processed by the LLM.
412
+ // Skip if a recent delivery is still being processed.
348
413
  // TTL-based lock: after successful POST, lock for 10 min to let the agent work.
349
414
  const lockUntil = deliveryLock.get(account.id);
350
415
  if (lockUntil && new Date() < lockUntil) {
351
416
  api.logger.debug?.(`[clawnet] ${account.id}: ${checkData.count} message(s) waiting (delivery lock active, skipping)`);
352
- return a2aDmCount;
417
+ return { a2aDmCount, sentTaskUpdates, notifyCount };
353
418
  }
354
419
 
355
420
  state.lastInboxNonEmptyAt = new Date();
356
- api.logger.info(`[clawnet] ${account.id}: ${checkData.count} message(s) waiting`);
421
+ api.logger.info(`[clawnet] ${account.id}: ${checkData.count} message(s) waiting (${notifyCount} to notify)`);
357
422
 
358
423
  // Fetch full messages
359
424
  const inboxRes = await fetch(`${cfg.baseUrl}/inbox`, { headers });
@@ -362,7 +427,7 @@ export function createClawnetService(params: { api: any; cfg: ClawnetConfig }) {
362
427
  }
363
428
  const inboxData = (await inboxRes.json()) as { messages: Array<Record<string, any>> };
364
429
 
365
- if (inboxData.messages.length === 0) return a2aDmCount;
430
+ if (inboxData.messages.length === 0) return { a2aDmCount, sentTaskUpdates, notifyCount };
366
431
 
367
432
  // Normalize API field names: API returns "from", plugin uses "from_agent"
368
433
  const normalized: InboxMessage[] = inboxData.messages.map((m) => ({
@@ -371,6 +436,7 @@ export function createClawnetService(params: { api: any; cfg: ClawnetConfig }) {
371
436
  content: m.content,
372
437
  subject: m.email?.subject ?? m.subject,
373
438
  created_at: m.created_at,
439
+ type: "email" as const,
374
440
  }));
375
441
 
376
442
  state.counters.messagesSeen += normalized.length;
@@ -380,7 +446,7 @@ export function createClawnetService(params: { api: any; cfg: ClawnetConfig }) {
380
446
  pendingMessages.set(account.id, [...existing, ...normalized]);
381
447
  scheduleFlush(account.id, account.agentId);
382
448
 
383
- return a2aDmCount;
449
+ return { a2aDmCount, sentTaskUpdates, notifyCount };
384
450
  }
385
451
 
386
452
  async function pollAccountA2A(account: ClawnetAccount, a2aDmCount: number) {
@@ -422,7 +488,8 @@ export function createClawnetService(params: { api: any; cfg: ClawnetConfig }) {
422
488
 
423
489
  api.logger.info(`[clawnet] ${account.id}: ${tasks.length} A2A task(s) to deliver`);
424
490
 
425
- // Convert A2A tasks to the message format the hook expects
491
+ // Convert A2A tasks to the message format for delivery
492
+ // Working transition + mark-notified happen post-delivery in deliverBatch
426
493
  const messages: InboxMessage[] = tasks.map((task) => {
427
494
  const history = task.history as Array<{ role: string; parts: Array<{ text?: string }> }> ?? [];
428
495
  const lastMsg = history[history.length - 1];
@@ -433,6 +500,7 @@ export function createClawnetService(params: { api: any; cfg: ClawnetConfig }) {
433
500
  from_agent: task.from,
434
501
  content: `[A2A Task ${task.id}]${contactInfo}\n${text}`,
435
502
  created_at: (task.status as any)?.timestamp ?? new Date().toISOString(),
503
+ type: "task" as const,
436
504
  };
437
505
  });
438
506
 
@@ -440,28 +508,59 @@ export function createClawnetService(params: { api: any; cfg: ClawnetConfig }) {
440
508
  const existing = pendingMessages.get(account.id) ?? [];
441
509
  pendingMessages.set(account.id, [...existing, ...messages]);
442
510
  scheduleFlush(account.id, account.agentId);
511
+ }
443
512
 
444
- // Mark delivered tasks as 'working' so they don't get re-delivered on next poll.
445
- // This is the equivalent of marking emails 'read' — acknowledges receipt.
446
- for (const task of tasks) {
447
- try {
448
- await fetch(`${cfg.baseUrl}/a2a`, {
449
- method: "POST",
450
- headers: {
451
- Authorization: `Bearer ${resolvedToken}`,
452
- "Content-Type": "application/json",
453
- },
454
- body: JSON.stringify({
455
- jsonrpc: "2.0",
456
- id: `ack-${task.id}`,
457
- method: "tasks/respond",
458
- params: { id: task.id, state: "working" },
459
- }),
460
- });
461
- } catch {
462
- // Non-fatal — task may get re-delivered next cycle
463
- }
464
- }
513
+ async function pollSentTaskUpdates(account: ClawnetAccount) {
514
+ const resolvedToken = resolveToken(account.token);
515
+ if (!resolvedToken) return;
516
+
517
+ // Skip if delivery lock active
518
+ const lockUntil = deliveryLock.get(account.id);
519
+ if (lockUntil && new Date() < lockUntil) return;
520
+
521
+ // Fetch tasks I sent that need my attention or have finished
522
+ const body = {
523
+ jsonrpc: "2.0",
524
+ id: `sent-poll-${Date.now()}`,
525
+ method: "tasks/list",
526
+ params: { role: "sender", status: "input-required,completed,failed", limit: 50 },
527
+ };
528
+ const res = await fetch(`${cfg.baseUrl}/a2a`, {
529
+ method: "POST",
530
+ headers: {
531
+ Authorization: `Bearer ${resolvedToken}`,
532
+ "Content-Type": "application/json",
533
+ },
534
+ body: JSON.stringify(body),
535
+ });
536
+ if (!res.ok) return;
537
+
538
+ const data = (await res.json()) as {
539
+ result?: { tasks: Array<Record<string, any>> };
540
+ };
541
+ const tasks = data.result?.tasks ?? [];
542
+ if (tasks.length === 0) return;
543
+
544
+ api.logger.info(`[clawnet] ${account.id}: ${tasks.length} sent task update(s) to deliver`);
545
+
546
+ const messages: InboxMessage[] = tasks.map((task) => {
547
+ const history = task.history as Array<{ role: string; parts: Array<{ text?: string }> }> ?? [];
548
+ const lastMsg = history[history.length - 1];
549
+ const text = lastMsg?.parts?.map((p: any) => p.text).filter(Boolean).join("\n") ?? "";
550
+ const taskState = task.state ?? "unknown";
551
+ return {
552
+ id: task.id,
553
+ from_agent: task.to, // the agent that responded
554
+ content: `[Task update: ${taskState}] Re: "${text.slice(0, 100)}${text.length > 100 ? "…" : ""}"`,
555
+ created_at: (task.status as any)?.timestamp ?? new Date().toISOString(),
556
+ type: "task" as const,
557
+ };
558
+ });
559
+
560
+ state.counters.messagesSeen += messages.length;
561
+ const existing = pendingMessages.get(account.id) ?? [];
562
+ pendingMessages.set(account.id, [...existing, ...messages]);
563
+ scheduleFlush(account.id, account.agentId);
465
564
  }
466
565
 
467
566
  async function tick() {
@@ -508,7 +607,7 @@ export function createClawnetService(params: { api: any; cfg: ClawnetConfig }) {
508
607
  let hadError = false;
509
608
  for (const account of enabledAccounts) {
510
609
  try {
511
- const a2aDmCount = await pollAccount(account);
610
+ const { a2aDmCount, sentTaskUpdates } = await pollAccount(account);
512
611
 
513
612
  // Also poll for A2A DMs if any pending
514
613
  if (a2aDmCount > 0) {
@@ -518,6 +617,15 @@ export function createClawnetService(params: { api: any; cfg: ClawnetConfig }) {
518
617
  api.logger.error(`[clawnet] A2A poll error for ${account.id}: ${a2aErr.message}`);
519
618
  }
520
619
  }
620
+
621
+ // Poll for sent task updates (tasks I sent that got a response)
622
+ if (sentTaskUpdates > 0) {
623
+ try {
624
+ await pollSentTaskUpdates(account);
625
+ } catch (err: any) {
626
+ api.logger.error(`[clawnet] Sent task updates error for ${account.id}: ${err.message}`);
627
+ }
628
+ }
521
629
  } catch (err: any) {
522
630
  hadError = true;
523
631
  state.lastError = { message: err.message, at: new Date() };
package/src/tools.ts CHANGED
@@ -155,6 +155,8 @@ interface CapabilityOp {
155
155
  description: string;
156
156
  params?: Record<string, { type: string; description: string; required?: boolean }>;
157
157
  rawBodyParam?: string; // If set, send this param as raw text body instead of JSON
158
+ jsonrpc?: boolean; // If true, dispatch via a2aCall() instead of REST
159
+ rpc_method?: string; // JSON-RPC method name (required when jsonrpc is true)
158
160
  }
159
161
 
160
162
  const BUILTIN_OPERATIONS: CapabilityOp[] = [
@@ -173,15 +175,11 @@ const BUILTIN_OPERATIONS: CapabilityOp[] = [
173
175
  { operation: "email.allowlist.remove", method: "DELETE", path: "/email/allowlist", description: "Remove sender from email allowlist", params: {
174
176
  pattern: { type: "string", description: "Email address or pattern to remove", required: true },
175
177
  }},
176
- // DMs (legacy — kept for backward compat during transition)
177
- { operation: "dm.send", method: "POST", path: "/send", description: "[Legacy] Send a DM to another ClawNet agent. Prefer a2a.send for new messages.", params: {
178
- to: { type: "string", description: "Recipient agent name", required: true },
179
- message: { type: "string", description: "Message content (max 10000 chars)", required: true },
180
- }},
181
- { operation: "dm.block", method: "POST", path: "/block", description: "Block an agent from DMing you", params: {
178
+ // Agent moderation
179
+ { operation: "agent.block", method: "POST", path: "/block", description: "Block an agent from contacting you", params: {
182
180
  agent_id: { type: "string", description: "Agent to block", required: true },
183
181
  }},
184
- { operation: "dm.unblock", method: "POST", path: "/unblock", description: "Unblock an agent", params: {
182
+ { operation: "agent.unblock", method: "POST", path: "/unblock", description: "Unblock an agent", params: {
185
183
  agent_id: { type: "string", description: "Agent to unblock", required: true },
186
184
  }},
187
185
  // Messages (cross-cutting)
@@ -222,8 +220,10 @@ const BUILTIN_OPERATIONS: CapabilityOp[] = [
222
220
  title: { type: "string", description: "Event title", required: true },
223
221
  starts_at: { type: "string", description: "ISO 8601 start time", required: true },
224
222
  ends_at: { type: "string", description: "ISO 8601 end time" },
223
+ all_day: { type: "boolean", description: "Mark as all-day event (spans full calendar day)" },
225
224
  location: { type: "string", description: "Event location" },
226
225
  description: { type: "string", description: "Event description" },
226
+ remind_minutes: { type: "number", description: "Minutes before event to send notification (0-10080, default 15, null to disable)" },
227
227
  attendees: { type: "array", description: "Array of {email, name?} — each gets a .ics invite" },
228
228
  }},
229
229
  { operation: "calendar.list", method: "GET", path: "/calendar/events", description: "List calendar events", params: {
@@ -235,7 +235,11 @@ const BUILTIN_OPERATIONS: CapabilityOp[] = [
235
235
  event_id: { type: "string", description: "Event ID", required: true },
236
236
  title: { type: "string", description: "New title" },
237
237
  starts_at: { type: "string", description: "New start time" },
238
+ ends_at: { type: "string", description: "New end time" },
239
+ all_day: { type: "boolean", description: "Mark as all-day event" },
238
240
  location: { type: "string", description: "New location" },
241
+ description: { type: "string", description: "New description" },
242
+ remind_minutes: { type: "number", description: "Minutes before event to send notification (0-10080, null to disable)" },
239
243
  }},
240
244
  { operation: "calendar.delete", method: "DELETE", path: "/calendar/events/:event_id", description: "Delete event (sends cancellation to attendees)", params: {
241
245
  event_id: { type: "string", description: "Event ID", required: true },
@@ -266,6 +270,30 @@ const BUILTIN_OPERATIONS: CapabilityOp[] = [
266
270
  { operation: "account.rate_limits", method: "GET", path: "/me/rate-limits", description: "Check your current rate limits" },
267
271
  // Docs
268
272
  { operation: "docs.help", method: "GET", path: "/docs/skill", description: "Get the full ClawNet documentation — features, usage examples, safety rules, setup, troubleshooting, and rate limits" },
273
+ // A2A (JSON-RPC via /a2a)
274
+ { operation: "a2a.card.update", jsonrpc: true, method: "POST", path: "/a2a", rpc_method: "card/update", description: "Update your A2A Agent Card skills", params: {
275
+ skills: { type: "array", description: "Array of {id, name, description} skill objects", required: true },
276
+ }},
277
+ { operation: "a2a.tasks.list", jsonrpc: true, method: "POST", path: "/a2a", rpc_method: "tasks/list", description: "List your A2A tasks", params: {
278
+ status: { type: "string", description: "Filter by status (e.g. 'submitted', 'working', 'completed', 'failed', comma-separated for multiple)" },
279
+ role: { type: "string", description: "'sender' or 'recipient'" },
280
+ limit: { type: "number", description: "Max tasks to return" },
281
+ }},
282
+ { operation: "a2a.tasks.get", jsonrpc: true, method: "POST", path: "/a2a", rpc_method: "tasks/get", description: "Get a specific A2A task by ID", params: {
283
+ id: { type: "string", description: "Task ID", required: true },
284
+ }},
285
+ { operation: "a2a.tasks.respond", jsonrpc: true, method: "POST", path: "/a2a", rpc_method: "tasks/respond", description: "Respond to an A2A task", params: {
286
+ id: { type: "string", description: "Task ID", required: true },
287
+ state: { type: "string", description: "New state: completed, input-required, working, or failed", required: true },
288
+ message: { type: "string", description: "Response message text" },
289
+ artifacts: { type: "array", description: "Array of artifact objects (for completed tasks)" },
290
+ }},
291
+ { operation: "a2a.tasks.cancel", jsonrpc: true, method: "POST", path: "/a2a", rpc_method: "tasks/cancel", description: "Cancel an A2A task", params: {
292
+ id: { type: "string", description: "Task ID", required: true },
293
+ }},
294
+ { operation: "a2a.tasks.count", jsonrpc: true, method: "POST", path: "/a2a", rpc_method: "tasks/count", description: "Count A2A tasks by status", params: {
295
+ status: { type: "string", description: "Filter by status" },
296
+ }},
269
297
  ];
270
298
 
271
299
  // --- Dynamic capabilities ---
@@ -335,7 +363,7 @@ export function registerTools(api: any) {
335
363
 
336
364
  api.registerTool((ctx: { agentId?: string; sessionKey?: string }) => ({
337
365
  name: "clawnet_inbox_check",
338
- description: toolDesc("clawnet_inbox_check", "Check if you have new messages. Returns total count and breakdown by type (email, DMs). Lightweight — use this before fetching full inbox. Use clawnet_email_inbox for emails, or clawnet_call with dm.inbox for DMs."),
366
+ description: toolDesc("clawnet_inbox_check", "Check if you have new messages. Returns total count and breakdown by type (email, tasks). Lightweight — use this before fetching full inbox. Use clawnet_email_inbox for emails, or clawnet_task_inbox for agent tasks."),
339
367
  parameters: {
340
368
  type: "object",
341
369
  properties: {},
@@ -436,7 +464,7 @@ export function registerTools(api: any) {
436
464
 
437
465
  api.registerTool((ctx: { agentId?: string; sessionKey?: string }) => ({
438
466
  name: "clawnet_inbox_session",
439
- description: toolDesc("clawnet_inbox_session", "Start an interactive email inbox session. Returns your emails with assigned numbers and a triage protocol for presenting them to your human. Use this when your human asks to manage, check, or go through their email."),
467
+ description: toolDesc("clawnet_inbox_session", "Start an interactive inbox session. Returns your emails with assigned numbers and a triage protocol. IMPORTANT: After calling this tool, also call clawnet_task_inbox to get pending agent tasks present both emails and tasks together to your human."),
440
468
  parameters: {
441
469
  type: "object",
442
470
  properties: {
@@ -493,10 +521,40 @@ export function registerTools(api: any) {
493
521
  };
494
522
  });
495
523
 
524
+ // Fetch A2A tasks via REST-style POST to /a2a
525
+ let tasks: Array<Record<string, unknown>> = [];
526
+ try {
527
+ const taskResult = await apiCall(cfg, "POST", "/a2a", {
528
+ jsonrpc: "2.0",
529
+ id: `inbox-${Date.now()}`,
530
+ method: "tasks/list",
531
+ params: { status: "submitted,working" },
532
+ }, ctx?.agentId, ctx?.sessionKey);
533
+ const taskData = taskResult.data as any;
534
+ const rawTasks = taskData?.result?.tasks ?? taskData?.tasks ?? [];
535
+ tasks = rawTasks.map((t: any, i: number) => {
536
+ const lastMsg = (t.history ?? []).slice(-1)[0];
537
+ const text = lastMsg?.parts?.map((p: any) => p.text).filter(Boolean).join("\n") ?? "";
538
+ return {
539
+ n: emails.length + i + 1,
540
+ id: t.id,
541
+ type: "a2a_task",
542
+ from: t.from,
543
+ trust_tier: t.trustTier ?? "public",
544
+ content: text.slice(0, 200),
545
+ state: t.status?.state ?? "unknown",
546
+ received_at: t.status?.timestamp,
547
+ };
548
+ });
549
+ } catch {
550
+ // Non-fatal — show emails even if task fetch fails
551
+ }
552
+
496
553
  return textResult({
497
554
  protocol,
498
555
  emails,
499
- counts: { total: emails.length, new: newCount, read: readCount },
556
+ tasks,
557
+ counts: { total: emails.length + tasks.length, emails: emails.length, tasks: tasks.length, new: newCount, read: readCount },
500
558
  });
501
559
  },
502
560
  }));
@@ -505,11 +563,11 @@ export function registerTools(api: any) {
505
563
 
506
564
  api.registerTool((ctx: { agentId?: string; sessionKey?: string }) => ({
507
565
  name: "clawnet_task_send",
508
- description: toolDesc("clawnet_task_send", "Send a task to another ClawNet agent. Use this when you need something from another agent — a question answered, an action performed, information looked up. Returns a task ID to check for their response later. For fire-and-forget notifications, use email instead."),
566
+ description: toolDesc("clawnet_task_send", "Send a task to another agent. Works with ClawNet agent names (e.g. 'Tom') or external A2A endpoint URLs (e.g. 'https://example.com/a2a/agent'). Use this when you need something from another agent — a question answered, an action performed, information looked up. Returns a task ID to check for their response later. For fire-and-forget notifications, use email instead."),
509
567
  parameters: {
510
568
  type: "object",
511
569
  properties: {
512
- to: { type: "string", description: "Recipient agent name" },
570
+ to: { type: "string", description: "Recipient: agent name (e.g. 'Tom') or external A2A URL (e.g. 'https://example.com/a2a/agent')" },
513
571
  message: { type: "string", description: "Message content" },
514
572
  task_id: { type: "string", description: "If following up on a task (after agent asked for input), provide the task ID" },
515
573
  },
@@ -517,6 +575,26 @@ export function registerTools(api: any) {
517
575
  },
518
576
  async execute(_id: string, params: { to: string; message: string; task_id?: string }) {
519
577
  const cfg = loadFreshConfig(api);
578
+ const isExternalUrl = params.to.startsWith("https://") || params.to.startsWith("http://");
579
+
580
+ if (params.to.startsWith("http://")) {
581
+ return textResult({ error: "Only HTTPS URLs are supported for external A2A endpoints. Use https:// instead of http://." });
582
+ }
583
+
584
+ if (isExternalUrl) {
585
+ // External A2A: route through tasks/send-external on internal endpoint
586
+ const a2aParams: Record<string, unknown> = {
587
+ url: params.to,
588
+ message: { role: "user", parts: [{ kind: "text", text: params.message }] },
589
+ };
590
+ if (params.task_id) {
591
+ a2aParams.taskId = params.task_id;
592
+ }
593
+ const result = await a2aCall(cfg, "/a2a", "tasks/send-external", a2aParams, ctx?.agentId, ctx?.sessionKey);
594
+ return textResult(result.data);
595
+ }
596
+
597
+ // Internal ClawNet agent: existing path
520
598
  const a2aParams: Record<string, unknown> = {
521
599
  message: { role: "user", parts: [{ kind: "text", text: params.message }] },
522
600
  };
@@ -553,14 +631,19 @@ export function registerTools(api: any) {
553
631
  parameters: {
554
632
  type: "object",
555
633
  properties: {
556
- status: { type: "string", description: "Filter: 'submitted' (default), 'working', 'completed', 'failed', or 'all'" },
634
+ status: { type: "string", description: "Filter: 'pending' (default — shows submitted + working), 'submitted', 'working', 'completed', 'failed', or 'all'" },
557
635
  limit: { type: "number", description: "Max tasks (default 50, max 100)" },
558
636
  },
559
637
  },
560
638
  async execute(_id: string, params: { status?: string; limit?: number }) {
561
639
  const cfg = loadFreshConfig(api);
562
- const a2aParams: Record<string, unknown> = {};
563
- if (params.status) a2aParams.status = params.status;
640
+ const a2aParams: Record<string, unknown> = { role: "recipient" };
641
+ const statusFilter = params.status || "pending";
642
+ if (statusFilter === "pending") {
643
+ a2aParams.status = "submitted,working";
644
+ } else {
645
+ a2aParams.status = statusFilter;
646
+ }
564
647
  if (params.limit) a2aParams.limit = params.limit;
565
648
  const result = await a2aCall(cfg, "/a2a", "tasks/list", a2aParams, ctx?.agentId, ctx?.sessionKey);
566
649
  return textResult(result.data);
@@ -637,7 +720,7 @@ export function registerTools(api: any) {
637
720
  parameters: {
638
721
  type: "object",
639
722
  properties: {
640
- operation: { type: "string", description: "Operation name from clawnet_capabilities (e.g. 'dm.send', 'profile.update', 'calendar.create')" },
723
+ operation: { type: "string", description: "Operation name from clawnet_capabilities (e.g. 'agent.block', 'profile.update', 'calendar.create')" },
641
724
  params: { type: "object", description: "Operation parameters (see clawnet_capabilities for schema)" },
642
725
  },
643
726
  required: ["operation"],
@@ -683,6 +766,16 @@ export function registerTools(api: any) {
683
766
  if (query) path += (path.includes('?') ? '&' : '?') + query;
684
767
  }
685
768
 
769
+ // JSON-RPC operations: dispatch via a2aCall()
770
+ if (op.jsonrpc && op.rpc_method) {
771
+ const rpcParams: Record<string, unknown> = {};
772
+ for (const [key, val] of Object.entries(params)) {
773
+ if (val !== undefined) rpcParams[key] = val;
774
+ }
775
+ const result = await a2aCall(cfg, op.path, op.rpc_method, Object.keys(rpcParams).length > 0 ? rpcParams : undefined, ctx?.agentId, ctx?.sessionKey);
776
+ return textResult(result.data);
777
+ }
778
+
686
779
  // Build body for non-GET requests
687
780
  let body: Record<string, unknown> | undefined;
688
781
  let rawBody: string | undefined;