@omnixal/openclaw-nats-plugin 0.2.7 → 0.2.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.
@@ -11,6 +11,25 @@ export interface GatewayInjectPayload {
11
11
  };
12
12
  }
13
13
 
14
+ export class GatewayRpcError extends Error {
15
+ constructor(
16
+ public readonly rpcId: string,
17
+ public readonly errorCode: string,
18
+ public readonly errorMessage: string,
19
+ ) {
20
+ super(`Gateway RPC error [${rpcId}]: ${errorCode} — ${errorMessage}`);
21
+ this.name = 'GatewayRpcError';
22
+ }
23
+ }
24
+
25
+ interface PendingRequest {
26
+ resolve: () => void;
27
+ reject: (err: Error) => void;
28
+ timer: Timer;
29
+ }
30
+
31
+ const RPC_TIMEOUT_MS = 10_000;
32
+
14
33
  @Service()
15
34
  export class GatewayClientService extends BaseService implements OnModuleInit, OnModuleDestroy {
16
35
  private ws: WebSocket | null = null;
@@ -21,6 +40,7 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
21
40
  private requestId = 0;
22
41
  private wsUrl!: string;
23
42
  private token!: string;
43
+ private pendingRequests = new Map<string, PendingRequest>();
24
44
 
25
45
  async onModuleInit(): Promise<void> {
26
46
  this.wsUrl = this.config.get('gateway.wsUrl');
@@ -49,6 +69,12 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
49
69
  this.ws.onclose = () => {
50
70
  this.connected = false;
51
71
  this.connectSent = false;
72
+ // Reject all in-flight requests immediately — don't make callers wait for timeout
73
+ for (const [id, pending] of this.pendingRequests) {
74
+ clearTimeout(pending.timer);
75
+ pending.reject(new Error('Gateway WebSocket closed'));
76
+ }
77
+ this.pendingRequests.clear();
52
78
  this.scheduleReconnect();
53
79
  };
54
80
 
@@ -97,12 +123,26 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
97
123
  }
98
124
  // Regular RPC ok response (e.g. for inject calls)
99
125
  this.logger.debug('Received RPC ok response', { id: frame.id });
126
+ this.resolvePending(frame.id);
100
127
  return;
101
128
  }
102
129
 
103
130
  // Error response
104
131
  if (frame.type === 'res' && frame.ok === false) {
105
- this.logger.warn('Gateway RPC error', { id: frame.id, error: frame.error });
132
+ const errorCode = frame.error?.code ?? frame.error?.errorCode ?? 'UNKNOWN';
133
+ const errorMessage = frame.error?.message ?? frame.error?.errorMessage ?? 'Unknown gateway error';
134
+ this.logger.error('Gateway RPC error', { id: frame.id, errorCode, errorMessage });
135
+
136
+ // If this is a connect error, close and reconnect
137
+ if (frame.id?.startsWith('connect-')) {
138
+ this.logger.error(`Gateway rejected connection: ${errorCode} — ${errorMessage}`);
139
+ this.connected = false;
140
+ this.connectSent = false;
141
+ this.ws?.close();
142
+ return;
143
+ }
144
+
145
+ this.rejectPending(frame.id, new GatewayRpcError(frame.id, String(errorCode), String(errorMessage)));
106
146
  }
107
147
  }
108
148
 
@@ -111,7 +151,8 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
111
151
  this.connectSent = true;
112
152
  this.logger.info('Sending connect frame');
113
153
 
114
- this.send({
154
+ try {
155
+ this.send({
115
156
  type: 'req',
116
157
  id: `connect-${++this.requestId}`,
117
158
  method: 'connect',
@@ -126,7 +167,7 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
126
167
  mode: 'backend',
127
168
  },
128
169
  role: 'operator',
129
- scopes: ['operator.read'],
170
+ scopes: ['operator.read', 'operator.write'],
130
171
  caps: [],
131
172
  commands: [],
132
173
  permissions: {},
@@ -135,11 +176,21 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
135
176
  userAgent: 'nats-sidecar/1.0.0',
136
177
  },
137
178
  });
179
+ } catch (err) {
180
+ this.logger.error('Failed to send connect frame', err);
181
+ this.connectSent = false;
182
+ }
138
183
  }
139
184
 
140
185
  private send(frame: unknown): void {
141
- if (this.ws?.readyState === WebSocket.OPEN) {
186
+ if (this.ws?.readyState !== WebSocket.OPEN) {
187
+ throw new Error('WebSocket is not open');
188
+ }
189
+ try {
142
190
  this.ws.send(JSON.stringify(frame));
191
+ } catch (err) {
192
+ this.connected = false;
193
+ throw new Error(`WebSocket send failed: ${err instanceof Error ? err.message : String(err)}`);
143
194
  }
144
195
  }
145
196
 
@@ -158,9 +209,11 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
158
209
  if (!this.isAlive()) {
159
210
  throw new Error('Gateway WebSocket not connected');
160
211
  }
212
+ const id = `rpc-${++this.requestId}`;
213
+ const promise = this.trackRequest(id);
161
214
  this.send({
162
215
  type: 'req',
163
- id: `rpc-${++this.requestId}`,
216
+ id,
164
217
  method: 'send',
165
218
  params: {
166
219
  target: payload.target,
@@ -169,6 +222,35 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
169
222
  idempotencyKey: payload.metadata?.eventId ?? String(this.requestId),
170
223
  },
171
224
  });
225
+ return promise;
226
+ }
227
+
228
+ private trackRequest(id: string): Promise<void> {
229
+ return new Promise<void>((resolve, reject) => {
230
+ const timer = setTimeout(() => {
231
+ this.pendingRequests.delete(id);
232
+ reject(new Error(`Gateway RPC timeout after ${RPC_TIMEOUT_MS}ms [${id}]`));
233
+ }, RPC_TIMEOUT_MS);
234
+ this.pendingRequests.set(id, { resolve, reject, timer });
235
+ });
236
+ }
237
+
238
+ private resolvePending(id: string): void {
239
+ const pending = this.pendingRequests.get(id);
240
+ if (pending) {
241
+ clearTimeout(pending.timer);
242
+ this.pendingRequests.delete(id);
243
+ pending.resolve();
244
+ }
245
+ }
246
+
247
+ private rejectPending(id: string, err: Error): void {
248
+ const pending = this.pendingRequests.get(id);
249
+ if (pending) {
250
+ clearTimeout(pending.timer);
251
+ this.pendingRequests.delete(id);
252
+ pending.reject(err);
253
+ }
172
254
  }
173
255
 
174
256
  isAlive(): boolean {
@@ -180,6 +262,12 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
180
262
  clearTimeout(this.reconnectTimer);
181
263
  this.reconnectTimer = null;
182
264
  }
265
+ // Reject all pending requests
266
+ for (const [id, pending] of this.pendingRequests) {
267
+ clearTimeout(pending.timer);
268
+ pending.reject(new Error('Gateway client shutting down'));
269
+ }
270
+ this.pendingRequests.clear();
183
271
  if (this.ws) {
184
272
  this.ws.close();
185
273
  this.ws = null;
@@ -35,7 +35,6 @@ export class RouterController extends BaseController {
35
35
  @Get('/health')
36
36
  async getRoutesHealth(): Promise<OneBunResponse> {
37
37
  const routes = await this.routerService.listRoutes();
38
- const now = Date.now();
39
38
  const result = routes.map(r => ({
40
39
  id: r.id,
41
40
  pattern: r.pattern,
@@ -45,7 +44,7 @@ export class RouterController extends BaseController {
45
44
  lastDeliveredAt: r.lastDeliveredAt?.toISOString() ?? null,
46
45
  lastEventSubject: r.lastEventSubject ?? null,
47
46
  deliveryCount: r.deliveryCount ?? 0,
48
- lagMs: r.lastDeliveredAt ? now - r.lastDeliveredAt.getTime() : null,
47
+ lagMs: r.lastDeliveryLagMs ?? null,
49
48
  }));
50
49
  return this.success(result);
51
50
  }
@@ -57,12 +57,13 @@ export class RouterRepository extends BaseService {
57
57
  return result ?? null;
58
58
  }
59
59
 
60
- async recordDelivery(routeId: string, subject: string): Promise<void> {
60
+ async recordDelivery(routeId: string, subject: string, lagMs: number): Promise<void> {
61
61
  await this.db.update(eventRoutes)
62
62
  .set({
63
63
  lastDeliveredAt: new Date(),
64
64
  lastEventSubject: subject,
65
65
  deliveryCount: sql`${eventRoutes.deliveryCount} + 1`,
66
+ lastDeliveryLagMs: lagMs,
66
67
  })
67
68
  .where(eq(eventRoutes.id, routeId));
68
69
  }
@@ -58,8 +58,8 @@ export class RouterService extends BaseService {
58
58
  return this.repo.updateById(id, fields);
59
59
  }
60
60
 
61
- async recordDelivery(routeId: string, subject: string): Promise<void> {
62
- await this.repo.recordDelivery(routeId, subject);
61
+ async recordDelivery(routeId: string, subject: string, lagMs: number): Promise<void> {
62
+ await this.repo.recordDelivery(routeId, subject, lagMs);
63
63
  }
64
64
 
65
65
  async unsubscribe(pattern: string): Promise<boolean> {
@@ -14,6 +14,7 @@ import { ApiKeyMiddleware } from '../auth/api-key.middleware';
14
14
  import {
15
15
  createCronBodySchema, type CreateCronBody,
16
16
  updateCronBodySchema, type UpdateCronBody,
17
+ createTimerBodySchema, type CreateTimerBody,
17
18
  isValidAgentSubject,
18
19
  } from '../validation/schemas';
19
20
 
@@ -92,6 +93,37 @@ export class SchedulerController extends BaseController {
92
93
  return this.success({ deleted: true });
93
94
  }
94
95
 
96
+ // ── Timer Endpoints ──────────────────────────────────────────────
97
+
98
+ @Post('/timer')
99
+ async createTimer(@Body(createTimerBodySchema) body: CreateTimerBody): Promise<OneBunResponse> {
100
+ if (!isValidAgentSubject(body.subject)) {
101
+ return this.error('subject must start with "agent.events." followed by at least one token and must not end with "."', 400, 400);
102
+ }
103
+ const timer = await this.scheduler.addTimer({
104
+ name: body.name,
105
+ delayMs: body.delayMs,
106
+ subject: body.subject,
107
+ payload: body.payload,
108
+ });
109
+ return this.success(timer);
110
+ }
111
+
112
+ @Get('/timer')
113
+ async listTimers(): Promise<OneBunResponse> {
114
+ const timers = await this.scheduler.listTimers();
115
+ return this.success(timers);
116
+ }
117
+
118
+ @Delete('/timer/:name')
119
+ async cancelTimer(@Param('name') name: string): Promise<OneBunResponse> {
120
+ const deleted = await this.scheduler.cancelTimer(name);
121
+ if (!deleted) return this.error('Timer not found', 404, 404);
122
+ return this.success({ deleted: true });
123
+ }
124
+
125
+ // ── Queue Handlers ────────────────────────────────────────────────
126
+
95
127
  @Subscribe('scheduler.fire.*', { group: 'scheduler-handler' })
96
128
  async handleFire(message: Message<unknown>): Promise<void> {
97
129
  const jobName = message.pattern?.split('.').pop();
@@ -99,4 +131,12 @@ export class SchedulerController extends BaseController {
99
131
  await this.scheduler.handleFire(jobName);
100
132
  }
101
133
  }
134
+
135
+ @Subscribe('scheduler.timer.*', { group: 'scheduler-handler' })
136
+ async handleTimerFire(message: Message<unknown>): Promise<void> {
137
+ const timerName = message.pattern?.split('.').pop();
138
+ if (timerName) {
139
+ await this.scheduler.handleTimerFire(timerName);
140
+ }
141
+ }
102
142
  }
@@ -1,6 +1,6 @@
1
1
  import { Service, BaseService } from '@onebun/core';
2
- import { DrizzleService, eq, sql } from '@onebun/drizzle';
3
- import { cronJobs, type DbCronJob, type NewCronJob } from '../db/schema';
2
+ import { DrizzleService, eq, sql, and, lte } from '@onebun/drizzle';
3
+ import { cronJobs, timerJobs, type DbCronJob, type NewCronJob, type DbTimerJob, type NewTimerJob } from '../db/schema';
4
4
 
5
5
  @Service()
6
6
  export class SchedulerRepository extends BaseService {
@@ -69,4 +69,51 @@ export class SchedulerRepository extends BaseService {
69
69
  .set({ lastRunAt: new Date() })
70
70
  .where(eq(cronJobs.name, name));
71
71
  }
72
+
73
+ // ── Timer Jobs ──────────────────────────────────────────────────────
74
+
75
+ async createTimer(timer: NewTimerJob): Promise<DbTimerJob> {
76
+ const [result] = await this.db
77
+ .insert(timerJobs)
78
+ .values(timer)
79
+ .onConflictDoUpdate({
80
+ target: timerJobs.name,
81
+ set: {
82
+ subject: sql`excluded.subject`,
83
+ payload: sql`excluded.payload`,
84
+ delayMs: sql`excluded.delay_ms`,
85
+ fireAt: sql`excluded.fire_at`,
86
+ fired: sql`0`,
87
+ },
88
+ })
89
+ .returning();
90
+ return result;
91
+ }
92
+
93
+ async findPendingTimers(): Promise<DbTimerJob[]> {
94
+ return this.db.select().from(timerJobs)
95
+ .where(eq(timerJobs.fired, false)) as any;
96
+ }
97
+
98
+ async findTimerByName(name: string): Promise<DbTimerJob | undefined> {
99
+ const [result] = await this.db.select().from(timerJobs)
100
+ .where(eq(timerJobs.name, name));
101
+ return result;
102
+ }
103
+
104
+ async markTimerFired(name: string): Promise<void> {
105
+ await this.db.update(timerJobs)
106
+ .set({ fired: true })
107
+ .where(eq(timerJobs.name, name));
108
+ }
109
+
110
+ async deleteTimerByName(name: string): Promise<boolean> {
111
+ const result = await this.db.delete(timerJobs)
112
+ .where(eq(timerJobs.name, name)).returning();
113
+ return result.length > 0;
114
+ }
115
+
116
+ async findAllTimers(): Promise<DbTimerJob[]> {
117
+ return this.db.select().from(timerJobs).orderBy(timerJobs.fireAt) as any;
118
+ }
72
119
  }
@@ -12,6 +12,13 @@ interface AddJobInput {
12
12
  timezone?: string;
13
13
  }
14
14
 
15
+ interface AddTimerInput {
16
+ name: string;
17
+ delayMs: number;
18
+ subject: string;
19
+ payload?: unknown;
20
+ }
21
+
15
22
  @Service()
16
23
  export class SchedulerService extends BaseService {
17
24
  private _queueReady = false;
@@ -51,6 +58,30 @@ export class SchedulerService extends BaseService {
51
58
  } catch (err) {
52
59
  this.logger.error('Failed to restore cron jobs', err);
53
60
  }
61
+
62
+ try {
63
+ const timers = await this.repo.findPendingTimers();
64
+ let restored = 0;
65
+ for (const timer of timers) {
66
+ const remaining = timer.fireAt.getTime() - Date.now();
67
+ if (remaining <= 0) {
68
+ // Timer expired during downtime — fire immediately
69
+ await this.fireTimer(timer.name);
70
+ } else {
71
+ this.scheduler.addTimeoutJob(
72
+ `timer.${timer.name}`,
73
+ remaining,
74
+ `scheduler.timer.${timer.name}`,
75
+ );
76
+ restored++;
77
+ }
78
+ }
79
+ if (restored > 0) {
80
+ this.logger.info(`Restored ${restored} pending timers from DB`);
81
+ }
82
+ } catch (err) {
83
+ this.logger.error('Failed to restore timers', err);
84
+ }
54
85
  }
55
86
 
56
87
  async add(input: AddJobInput) {
@@ -201,4 +232,80 @@ export class SchedulerService extends BaseService {
201
232
  this.logger.error(`Cron fire failed: ${job.name}`, err);
202
233
  }
203
234
  }
235
+
236
+ // ── Timer (one-shot delayed) Jobs ─────────────────────────────────
237
+
238
+ async addTimer(input: AddTimerInput) {
239
+ const fireAt = new Date(Date.now() + input.delayMs);
240
+ const timer = await this.repo.createTimer({
241
+ id: ulid(),
242
+ name: input.name,
243
+ subject: input.subject,
244
+ payload: input.payload ?? null,
245
+ delayMs: input.delayMs,
246
+ fireAt,
247
+ fired: false,
248
+ createdAt: new Date(),
249
+ });
250
+
251
+ const schedulerName = `timer.${input.name}`;
252
+ if (this.scheduler.hasJob(schedulerName)) {
253
+ this.scheduler.removeJob(schedulerName);
254
+ }
255
+ this.scheduler.addTimeoutJob(
256
+ schedulerName,
257
+ input.delayMs,
258
+ `scheduler.timer.${input.name}`,
259
+ );
260
+
261
+ this.logger.info(`Timer '${input.name}' set: ${input.delayMs}ms -> ${input.subject}`);
262
+ return { ...timer, fireAt: timer.fireAt.getTime(), createdAt: timer.createdAt.getTime() };
263
+ }
264
+
265
+ async cancelTimer(name: string): Promise<boolean> {
266
+ const deleted = await this.repo.deleteTimerByName(name);
267
+ const schedulerName = `timer.${name}`;
268
+ if (this.scheduler.hasJob(schedulerName)) {
269
+ this.scheduler.removeJob(schedulerName);
270
+ }
271
+ return deleted;
272
+ }
273
+
274
+ async listTimers() {
275
+ const timers = await this.repo.findAllTimers();
276
+ return timers.map(t => ({
277
+ ...t,
278
+ fireAt: t.fireAt.getTime(),
279
+ createdAt: t.createdAt.getTime(),
280
+ remainingMs: t.fired ? 0 : Math.max(0, t.fireAt.getTime() - Date.now()),
281
+ }));
282
+ }
283
+
284
+ async fireTimer(name: string): Promise<void> {
285
+ const timer = await this.repo.findTimerByName(name);
286
+ if (!timer || timer.fired) return;
287
+
288
+ if (!this._queueReady) {
289
+ this.logger.warn(`Timer '${name}' fire skipped — queue not ready`);
290
+ return;
291
+ }
292
+
293
+ try {
294
+ const base = (timer.payload && typeof timer.payload === 'object' && !Array.isArray(timer.payload))
295
+ ? (timer.payload as Record<string, unknown>)
296
+ : {};
297
+ const payload = { ...base, _timer: { name: timer.name, firedAt: new Date().toISOString() } };
298
+ await this.publisher.publish(timer.subject, payload);
299
+ await this.repo.markTimerFired(name);
300
+ await this.logService.logDelivery(timer.id, timer.subject, JSON.stringify({ type: 'timer', name }));
301
+ this.logger.info(`Timer fired: ${name} -> ${timer.subject}`);
302
+ } catch (err) {
303
+ await this.logService.logError('timer', timer.id, timer.subject, err);
304
+ this.logger.error(`Timer fire failed: ${name}`, err);
305
+ }
306
+ }
307
+
308
+ async handleTimerFire(timerName: string): Promise<void> {
309
+ await this.fireTimer(timerName);
310
+ }
204
311
  }
@@ -54,6 +54,15 @@ export const updateCronBodySchema = type({
54
54
 
55
55
  export type UpdateCronBody = typeof updateCronBodySchema.infer;
56
56
 
57
+ export const createTimerBodySchema = type({
58
+ name: 'string',
59
+ delayMs: 'number > 0',
60
+ subject: 'string',
61
+ 'payload?': 'unknown',
62
+ });
63
+
64
+ export type CreateTimerBody = typeof createTimerBodySchema.infer;
65
+
57
66
  /** Validate that subject has content after 'agent.events.' prefix and doesn't end with '.' */
58
67
  export function isValidAgentSubject(subject: string): boolean {
59
68
  if (!subject.startsWith('agent.events.')) return false;
@@ -1,22 +1,41 @@
1
1
  ---
2
2
  name: nats-events
3
- description: Event-driven patterns for NATS. Use when the agent needs to publish events, subscribe to event patterns, set up cron-based triggers, or react to incoming NATS messages. Triggers on any mention of NATS, events, subscriptions, cron scheduling, or event-driven workflows.
3
+ description: Event-driven patterns for NATS. Use when the agent needs to publish events, subscribe to event patterns, set up cron-based triggers, manage timers, or react to incoming NATS messages. Triggers on any mention of NATS, events, subscriptions, cron scheduling, timers, or event-driven workflows.
4
4
  ---
5
5
 
6
6
  # NATS Event Bus
7
7
 
8
8
  You have access to a NATS JetStream event bus via these tools:
9
9
 
10
+ ## Core Tools
11
+
10
12
  | Tool | Purpose |
11
13
  |---|---|
12
14
  | `nats_publish` | Publish an event (subject + payload) |
13
15
  | `nats_subscribe` | Create a routing rule (pattern -> session) — idempotent, safe to repeat |
14
16
  | `nats_unsubscribe` | Remove a routing rule by ID |
15
17
  | `nats_subscriptions` | List routing rules (optional filters: pattern, target) |
18
+ | `nats_route_update` | Update a route (target, priority, enabled) by ID |
19
+
20
+ ## Cron (Recurring) Tools
21
+
22
+ | Tool | Purpose |
23
+ |---|---|
16
24
  | `nats_cron_add` | Schedule a recurring NATS event (no LLM wake) |
25
+ | `nats_cron_update` | Update a cron job (schedule, subject, payload, timezone, enabled) |
26
+ | `nats_cron_toggle` | Toggle a cron job on/off |
27
+ | `nats_cron_run` | Manually fire a cron job right now |
17
28
  | `nats_cron_remove` | Remove a scheduled cron job |
18
29
  | `nats_cron_list` | List all scheduled cron jobs |
19
30
 
31
+ ## Timer (One-Shot Delayed) Tools
32
+
33
+ | Tool | Purpose |
34
+ |---|---|
35
+ | `nats_timer_set` | Set a one-shot delayed event (fires once after delay) |
36
+ | `nats_timer_cancel` | Cancel a pending timer |
37
+ | `nats_timer_list` | List all timers with remaining time |
38
+
20
39
  ## Bootstrap
21
40
 
22
41
  Before first use, set up default subscriptions. This is idempotent — safe to run multiple times:
@@ -26,6 +45,7 @@ nats_subscribe(pattern: "agent.events.cron.>", target: "main")
26
45
  nats_subscribe(pattern: "agent.events.subagent.>", target: "main")
27
46
  nats_subscribe(pattern: "agent.events.hook.>", target: "main")
28
47
  nats_subscribe(pattern: "agent.events.custom.>", target: "main")
48
+ nats_subscribe(pattern: "agent.events.timer.>", target: "main")
29
49
  ```
30
50
 
31
51
  ## Event-Driven Rules
@@ -46,33 +66,76 @@ nats_cron_add(
46
66
  payload: { "task": "daily_report" }
47
67
  )
48
68
 
49
- # Schedule hourly revenue check
50
- nats_cron_add(
51
- name: "hourly-check",
52
- cron: "0 * * * *",
53
- subject: "agent.events.cron.check-revenue",
54
- payload: { "task": "check_revenue" }
55
- )
69
+ # Update the schedule to 10am
70
+ nats_cron_update(name: "daily-report", cron: "0 10 * * *")
71
+
72
+ # Temporarily disable
73
+ nats_cron_toggle(name: "daily-report")
74
+
75
+ # Fire manually right now (doesn't affect schedule)
76
+ nats_cron_run(name: "daily-report")
56
77
 
57
78
  # List all scheduled jobs
58
79
  nats_cron_list()
59
80
 
60
81
  # Remove a job
61
- nats_cron_remove(name: "hourly-check")
82
+ nats_cron_remove(name: "daily-report")
62
83
  ```
63
84
 
64
- Don't forget to also subscribe to the cron subject so you receive the events:
85
+ ## Timers (Delayed One-Shot Events)
86
+
87
+ Use `nats_timer_set` for delayed self-pings, reminders, or deferred checks. Timers survive sidecar restarts.
88
+
65
89
  ```
66
- nats_subscribe(pattern: "agent.events.cron.>", target: "main")
90
+ # Check deploy status in 5 minutes
91
+ nats_timer_set(
92
+ name: "check-deploy",
93
+ delayMs: 300000,
94
+ subject: "agent.events.timer.check-deploy",
95
+ payload: { "task": "verify_deployment", "deployId": "abc123" }
96
+ )
97
+
98
+ # Set a 1-hour reminder
99
+ nats_timer_set(
100
+ name: "followup-reminder",
101
+ delayMs: 3600000,
102
+ subject: "agent.events.timer.reminder",
103
+ payload: { "task": "followup", "context": "check sales" }
104
+ )
105
+
106
+ # Cancel a timer
107
+ nats_timer_cancel(name: "check-deploy")
108
+
109
+ # List pending timers
110
+ nats_timer_list()
67
111
  ```
68
112
 
69
- **Alternative (environments with system crontab):** Use `nats-cron-trigger.sh` script.
113
+ Don't forget to subscribe to timer events:
114
+ ```
115
+ nats_subscribe(pattern: "agent.events.timer.>", target: "main")
116
+ ```
117
+
118
+ ## Managing Routes
119
+
120
+ ```
121
+ # List current routes
122
+ nats_subscriptions()
123
+
124
+ # Update a route's priority or target
125
+ nats_route_update(id: "01ABC...", priority: 8)
126
+ nats_route_update(id: "01ABC...", enabled: false)
127
+ nats_route_update(id: "01ABC...", target: "worker-2")
128
+
129
+ # Remove a route
130
+ nats_unsubscribe(id: "01ABC...")
131
+ ```
70
132
 
71
133
  ## Subject Hierarchy
72
134
 
73
135
  | Pattern | Use for |
74
136
  |---|---|
75
137
  | `agent.events.cron.*` | Scheduled task triggers |
138
+ | `agent.events.timer.*` | Delayed one-shot triggers |
76
139
  | `agent.events.subagent.spawned` | Subagent started |
77
140
  | `agent.events.subagent.ended` | Subagent completed |
78
141
  | `agent.events.hook.*` | External webhook triggers |
@@ -104,3 +167,9 @@ nats_publish(subject: "agent.events.custom.report-ready", payload: {"reportUrl":
104
167
  nats_subscribe(pattern: "agent.events.cron.daily-report", target: "main")
105
168
  nats_cron_add(name: "daily-report", cron: "0 9 * * *", subject: "agent.events.cron.daily-report", payload: {"task": "report"})
106
169
  ```
170
+
171
+ **Delayed check after action:**
172
+ ```
173
+ nats_subscribe(pattern: "agent.events.timer.>", target: "main")
174
+ nats_timer_set(name: "verify-action", delayMs: 60000, subject: "agent.events.timer.verify", payload: {"check": "result"})
175
+ ```