@omnixal/openclaw-nats-plugin 0.1.17 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "id": "openclaw-nats-plugin",
3
+ "skills": ["./skills"],
3
4
  "configSchema": {
4
5
  "type": "object",
5
6
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnixal/openclaw-nats-plugin",
3
- "version": "0.1.17",
3
+ "version": "0.2.0",
4
4
  "description": "NATS JetStream event-driven plugin for OpenClaw",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  "hooks/",
20
20
  "plugins/",
21
21
  "sidecar/",
22
+ "skills/",
22
23
  "docker/",
23
24
  "dashboard/dist/",
24
25
  "openclaw.plugin.json",
@@ -78,6 +78,100 @@ export default function (api: any) {
78
78
  }, { priority: 8 });
79
79
  }, { priority: 99 });
80
80
 
81
+ // ── Agent Tools ─────────────────────────────────────────────────
82
+
83
+ const SIDECAR_URL = process.env.NATS_SIDECAR_URL || 'http://127.0.0.1:3104';
84
+ const SIDECAR_KEY = process.env.NATS_PLUGIN_API_KEY || 'dev-nats-plugin-key';
85
+
86
+ const sidecarFetch = async (path: string, options: RequestInit = {}) => {
87
+ const res = await fetch(`${SIDECAR_URL}${path}`, {
88
+ ...options,
89
+ headers: {
90
+ 'Content-Type': 'application/json',
91
+ 'Authorization': `Bearer ${SIDECAR_KEY}`,
92
+ ...options.headers,
93
+ },
94
+ signal: AbortSignal.timeout(5000),
95
+ });
96
+ return res.json();
97
+ };
98
+
99
+ api.registerTool({
100
+ name: 'nats_publish',
101
+ description: 'Publish an event to the NATS event bus. Use for cron triggers, custom events, task notifications.',
102
+ parameters: {
103
+ type: 'object',
104
+ properties: {
105
+ subject: { type: 'string', description: 'Event subject (must start with agent.events.)' },
106
+ payload: { type: 'object', description: 'Event payload data' },
107
+ },
108
+ required: ['subject', 'payload'],
109
+ },
110
+ async execute(_id: string, params: any) {
111
+ const result = await sidecarFetch('/api/publish', {
112
+ method: 'POST',
113
+ body: JSON.stringify({ subject: params.subject, payload: params.payload }),
114
+ });
115
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
116
+ },
117
+ });
118
+
119
+ api.registerTool({
120
+ name: 'nats_subscribe',
121
+ description: 'Subscribe to events matching a pattern. Matched events will be delivered to the target session as messages.',
122
+ parameters: {
123
+ type: 'object',
124
+ properties: {
125
+ pattern: { type: 'string', description: 'Subject pattern (exact, or wildcard with * for one level, > for all descendants)' },
126
+ target: { type: 'string', description: 'Session key to deliver to (default: main)' },
127
+ },
128
+ required: ['pattern'],
129
+ },
130
+ async execute(_id: string, params: any) {
131
+ const result = await sidecarFetch('/api/routes', {
132
+ method: 'POST',
133
+ body: JSON.stringify({ pattern: params.pattern, target: params.target ?? 'main' }),
134
+ });
135
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
136
+ },
137
+ });
138
+
139
+ api.registerTool({
140
+ name: 'nats_unsubscribe',
141
+ description: 'Remove an event subscription by its ID.',
142
+ parameters: {
143
+ type: 'object',
144
+ properties: {
145
+ id: { type: 'string', description: 'Route ID to delete (from nats_subscriptions)' },
146
+ },
147
+ required: ['id'],
148
+ },
149
+ async execute(_id: string, params: any) {
150
+ const result = await sidecarFetch(`/api/routes/${params.id}`, { method: 'DELETE' });
151
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
152
+ },
153
+ });
154
+
155
+ api.registerTool({
156
+ name: 'nats_subscriptions',
157
+ description: 'List event subscriptions. Optionally filter by pattern or target session.',
158
+ parameters: {
159
+ type: 'object',
160
+ properties: {
161
+ pattern: { type: 'string', description: 'Filter: show routes matching this pattern' },
162
+ target: { type: 'string', description: 'Filter: show routes delivering to this session' },
163
+ },
164
+ },
165
+ async execute(_id: string, params: any) {
166
+ const qs = new URLSearchParams();
167
+ if (params?.pattern) qs.set('pattern', params.pattern);
168
+ if (params?.target) qs.set('target', params.target);
169
+ const path = qs.toString() ? `/api/routes?${qs}` : '/api/routes';
170
+ const result = await sidecarFetch(path);
171
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
172
+ },
173
+ });
174
+
81
175
  // ── Dashboard UI ─────────────────────────────────────────────────
82
176
 
83
177
  api.registerHttpRoute({
@@ -7,6 +7,7 @@ import { GatewayClientModule } from './gateway/gateway-client.module';
7
7
  import { ConsumerModule } from './consumer/consumer.module';
8
8
  import { PendingModule } from './pending/pending.module';
9
9
  import { HealthModule } from './health/health.module';
10
+ import { RouterModule } from './router/router.module';
10
11
 
11
12
  @Module({
12
13
  imports: [
@@ -26,6 +27,7 @@ import { HealthModule } from './health/health.module';
26
27
  ConsumerModule,
27
28
  PendingModule,
28
29
  HealthModule,
30
+ RouterModule,
29
31
  ],
30
32
  })
31
33
  export class AppModule {}
@@ -2,6 +2,7 @@ import { Controller, BaseController, Subscribe, OnQueueReady, type Message } fro
2
2
  import { PipelineService } from '../pre-handlers/pipeline.service';
3
3
  import { GatewayClientService } from '../gateway/gateway-client.service';
4
4
  import { PendingService } from '../pending/pending.service';
5
+ import { RouterService } from '../router/router.service';
5
6
  import type { NatsEventEnvelope } from '../publisher/envelope';
6
7
 
7
8
  @Controller('/consumer')
@@ -10,6 +11,7 @@ export class ConsumerController extends BaseController {
10
11
  private pipeline: PipelineService,
11
12
  private gatewayClient: GatewayClientService,
12
13
  private pendingService: PendingService,
14
+ private routerService: RouterService,
13
15
  ) {
14
16
  super();
15
17
  }
@@ -20,7 +22,7 @@ export class ConsumerController extends BaseController {
20
22
  this.logger.info(`Queue connected, consuming as ${consumerName}`);
21
23
  }
22
24
 
23
- @Subscribe('agent.inbound.>', {
25
+ @Subscribe('agent.events.>', {
24
26
  ackMode: 'manual',
25
27
  group: 'openclaw-main',
26
28
  })
@@ -29,29 +31,37 @@ export class ConsumerController extends BaseController {
29
31
  const envelope = this.extractEnvelope(message);
30
32
 
31
33
  const { result, ctx } = await this.pipeline.process(envelope);
32
-
33
34
  if (result === 'drop') {
34
35
  await message.ack();
35
36
  return;
36
37
  }
37
38
 
38
- // Deliver to Gateway
39
+ // Check routing rules
40
+ const routes = await this.routerService.findMatchingRoutes(envelope.subject);
41
+ if (routes.length === 0) {
42
+ // No route — just ack (event is in JetStream for audit)
43
+ await message.ack();
44
+ return;
45
+ }
46
+
47
+ // Deliver to each matching target
39
48
  if (this.gatewayClient.isAlive()) {
40
- await this.gatewayClient.inject({
41
- target: envelope.agentTarget ?? 'main',
42
- message: this.formatMessage(envelope),
43
- metadata: {
44
- source: 'nats',
45
- eventId: envelope.id,
46
- subject: envelope.subject,
47
- priority: (ctx.enrichments['priority'] as number) ?? envelope.meta?.priority ?? 5,
48
- },
49
- });
49
+ for (const route of routes) {
50
+ await this.gatewayClient.inject({
51
+ target: route.target,
52
+ message: this.formatMessage(envelope),
53
+ metadata: {
54
+ source: 'nats',
55
+ eventId: envelope.id,
56
+ subject: envelope.subject,
57
+ priority: (ctx.enrichments['priority'] as number) ?? envelope.meta?.priority ?? 5,
58
+ },
59
+ });
60
+ }
50
61
  await message.ack();
51
62
  } else {
52
- // Gateway not available — store as pending for ContextEngine pickup
53
63
  await this.pendingService.addPending(envelope);
54
- await message.ack(); // ack because we stored it locally
64
+ await message.ack();
55
65
  this.logger.warn(`Gateway unavailable, stored pending event ${envelope.id}`);
56
66
  }
57
67
  } catch (err) {
@@ -60,29 +70,14 @@ export class ConsumerController extends BaseController {
60
70
  }
61
71
  }
62
72
 
63
- /**
64
- * Extract the NatsEventEnvelope from the adapter message.
65
- *
66
- * The JetStreamQueueAdapter wraps messages in its own envelope:
67
- * { id, pattern, data, timestamp, metadata }
68
- *
69
- * Our NatsEventEnvelope is inside `data` when published via PublisherService,
70
- * or the raw data itself when published externally.
71
- */
72
73
  private extractEnvelope(message: Message<unknown>): NatsEventEnvelope {
73
74
  const data = message.data as any;
74
-
75
- // If the data already looks like a NatsEventEnvelope (has id, subject, payload),
76
- // use it directly.
77
75
  if (data && typeof data === 'object' && 'subject' in data && 'payload' in data) {
78
76
  return data as NatsEventEnvelope;
79
77
  }
80
-
81
- // Otherwise, treat it as a raw payload string that needs parsing
82
78
  if (typeof data === 'string') {
83
79
  return JSON.parse(data) as NatsEventEnvelope;
84
80
  }
85
-
86
81
  throw new Error('Unable to extract envelope from message');
87
82
  }
88
83
 
@@ -2,9 +2,10 @@ import { Module } from '@onebun/core';
2
2
  import { ConsumerController } from './consumer.controller';
3
3
  import { PreHandlersModule } from '../pre-handlers/pre-handlers.module';
4
4
  import { PendingModule } from '../pending/pending.module';
5
+ import { RouterModule } from '../router/router.module';
5
6
 
6
7
  @Module({
7
- imports: [PreHandlersModule, PendingModule],
8
+ imports: [PreHandlersModule, PendingModule, RouterModule],
8
9
  controllers: [ConsumerController],
9
10
  })
10
11
  export class ConsumerModule {}
@@ -0,0 +1,12 @@
1
+ CREATE TABLE `event_routes` (
2
+ `id` text PRIMARY KEY NOT NULL,
3
+ `pattern` text NOT NULL,
4
+ `target` text DEFAULT 'main' NOT NULL,
5
+ `enabled` integer DEFAULT true NOT NULL,
6
+ `priority` integer DEFAULT 5 NOT NULL,
7
+ `created_at` integer NOT NULL
8
+ );
9
+ --> statement-breakpoint
10
+ CREATE UNIQUE INDEX `event_routes_pattern_unique` ON `event_routes` (`pattern`);--> statement-breakpoint
11
+ CREATE INDEX `event_routes_pattern_idx` ON `event_routes` (`pattern`);--> statement-breakpoint
12
+ CREATE INDEX `event_routes_target_idx` ON `event_routes` (`target`);
@@ -0,0 +1,194 @@
1
+ {
2
+ "version": "6",
3
+ "dialect": "sqlite",
4
+ "id": "cb92737c-c257-4b58-bbf0-798ab1494961",
5
+ "prevId": "3836f3c7-1186-453d-bb01-c2f5e58a1f4f",
6
+ "tables": {
7
+ "dedup_events": {
8
+ "name": "dedup_events",
9
+ "columns": {
10
+ "event_id": {
11
+ "name": "event_id",
12
+ "type": "text",
13
+ "primaryKey": true,
14
+ "notNull": true,
15
+ "autoincrement": false
16
+ },
17
+ "subject": {
18
+ "name": "subject",
19
+ "type": "text",
20
+ "primaryKey": false,
21
+ "notNull": true,
22
+ "autoincrement": false
23
+ },
24
+ "seen_at": {
25
+ "name": "seen_at",
26
+ "type": "integer",
27
+ "primaryKey": false,
28
+ "notNull": true,
29
+ "autoincrement": false
30
+ }
31
+ },
32
+ "indexes": {
33
+ "dedup_events_seen_at_idx": {
34
+ "name": "dedup_events_seen_at_idx",
35
+ "columns": [
36
+ "seen_at"
37
+ ],
38
+ "isUnique": false
39
+ }
40
+ },
41
+ "foreignKeys": {},
42
+ "compositePrimaryKeys": {},
43
+ "uniqueConstraints": {},
44
+ "checkConstraints": {}
45
+ },
46
+ "event_routes": {
47
+ "name": "event_routes",
48
+ "columns": {
49
+ "id": {
50
+ "name": "id",
51
+ "type": "text",
52
+ "primaryKey": true,
53
+ "notNull": true,
54
+ "autoincrement": false
55
+ },
56
+ "pattern": {
57
+ "name": "pattern",
58
+ "type": "text",
59
+ "primaryKey": false,
60
+ "notNull": true,
61
+ "autoincrement": false
62
+ },
63
+ "target": {
64
+ "name": "target",
65
+ "type": "text",
66
+ "primaryKey": false,
67
+ "notNull": true,
68
+ "autoincrement": false,
69
+ "default": "'main'"
70
+ },
71
+ "enabled": {
72
+ "name": "enabled",
73
+ "type": "integer",
74
+ "primaryKey": false,
75
+ "notNull": true,
76
+ "autoincrement": false,
77
+ "default": true
78
+ },
79
+ "priority": {
80
+ "name": "priority",
81
+ "type": "integer",
82
+ "primaryKey": false,
83
+ "notNull": true,
84
+ "autoincrement": false,
85
+ "default": 5
86
+ },
87
+ "created_at": {
88
+ "name": "created_at",
89
+ "type": "integer",
90
+ "primaryKey": false,
91
+ "notNull": true,
92
+ "autoincrement": false
93
+ }
94
+ },
95
+ "indexes": {
96
+ "event_routes_pattern_unique": {
97
+ "name": "event_routes_pattern_unique",
98
+ "columns": [
99
+ "pattern"
100
+ ],
101
+ "isUnique": true
102
+ },
103
+ "event_routes_pattern_idx": {
104
+ "name": "event_routes_pattern_idx",
105
+ "columns": [
106
+ "pattern"
107
+ ],
108
+ "isUnique": false
109
+ },
110
+ "event_routes_target_idx": {
111
+ "name": "event_routes_target_idx",
112
+ "columns": [
113
+ "target"
114
+ ],
115
+ "isUnique": false
116
+ }
117
+ },
118
+ "foreignKeys": {},
119
+ "compositePrimaryKeys": {},
120
+ "uniqueConstraints": {},
121
+ "checkConstraints": {}
122
+ },
123
+ "pending_events": {
124
+ "name": "pending_events",
125
+ "columns": {
126
+ "id": {
127
+ "name": "id",
128
+ "type": "text",
129
+ "primaryKey": true,
130
+ "notNull": true,
131
+ "autoincrement": false
132
+ },
133
+ "session_key": {
134
+ "name": "session_key",
135
+ "type": "text",
136
+ "primaryKey": false,
137
+ "notNull": true,
138
+ "autoincrement": false
139
+ },
140
+ "subject": {
141
+ "name": "subject",
142
+ "type": "text",
143
+ "primaryKey": false,
144
+ "notNull": true,
145
+ "autoincrement": false
146
+ },
147
+ "payload": {
148
+ "name": "payload",
149
+ "type": "text",
150
+ "primaryKey": false,
151
+ "notNull": false,
152
+ "autoincrement": false
153
+ },
154
+ "priority": {
155
+ "name": "priority",
156
+ "type": "integer",
157
+ "primaryKey": false,
158
+ "notNull": true,
159
+ "autoincrement": false,
160
+ "default": 5
161
+ },
162
+ "created_at": {
163
+ "name": "created_at",
164
+ "type": "integer",
165
+ "primaryKey": false,
166
+ "notNull": true,
167
+ "autoincrement": false
168
+ },
169
+ "delivered_at": {
170
+ "name": "delivered_at",
171
+ "type": "integer",
172
+ "primaryKey": false,
173
+ "notNull": false,
174
+ "autoincrement": false
175
+ }
176
+ },
177
+ "indexes": {},
178
+ "foreignKeys": {},
179
+ "compositePrimaryKeys": {},
180
+ "uniqueConstraints": {},
181
+ "checkConstraints": {}
182
+ }
183
+ },
184
+ "views": {},
185
+ "enums": {},
186
+ "_meta": {
187
+ "schemas": {},
188
+ "tables": {},
189
+ "columns": {}
190
+ },
191
+ "internal": {
192
+ "indexes": {}
193
+ }
194
+ }
@@ -22,6 +22,13 @@
22
22
  "when": 1773334582658,
23
23
  "tag": "0002_common_stellaris",
24
24
  "breakpoints": true
25
+ },
26
+ {
27
+ "idx": 3,
28
+ "version": "6",
29
+ "when": 1773938595817,
30
+ "tag": "0003_wet_deathbird",
31
+ "breakpoints": true
25
32
  }
26
33
  ]
27
34
  }
@@ -20,3 +20,18 @@ export const pendingEvents = sqliteTable('pending_events', {
20
20
 
21
21
  export type DbPendingEvent = typeof pendingEvents.$inferSelect;
22
22
  export type NewPendingEvent = typeof pendingEvents.$inferInsert;
23
+
24
+ export const eventRoutes = sqliteTable('event_routes', {
25
+ id: text('id').primaryKey(),
26
+ pattern: text('pattern').notNull().unique(),
27
+ target: text('target').notNull().default('main'),
28
+ enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
29
+ priority: integer('priority').notNull().default(5),
30
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
31
+ }, (table) => [
32
+ index('event_routes_pattern_idx').on(table.pattern),
33
+ index('event_routes_target_idx').on(table.target),
34
+ ]);
35
+
36
+ export type DbEventRoute = typeof eventRoutes.$inferSelect;
37
+ export type NewEventRoute = typeof eventRoutes.$inferInsert;
@@ -119,7 +119,8 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
119
119
  minProtocol: 3,
120
120
  maxProtocol: 3,
121
121
  client: {
122
- id: 'nats-sidecar',
122
+ id: 'gateway-client',
123
+ displayName: 'nats-sidecar',
123
124
  version: '1.0.0',
124
125
  platform: 'linux',
125
126
  mode: 'backend',
@@ -49,7 +49,7 @@ export class HealthService extends BaseService {
49
49
  pendingCount,
50
50
  uptimeSeconds: Math.floor((Date.now() - this.startedAt) / 1000),
51
51
  config: {
52
- streams: ['agent_inbound', 'agent_events', 'agent_dlq'],
52
+ streams: ['agent_events', 'agent_dlq'],
53
53
  consumerName: this.config.get('consumer.name'),
54
54
  dedupTtlSeconds: this.config.get('dedup.ttlSeconds'),
55
55
  },
@@ -23,11 +23,6 @@ const app = new OneBunApplication(AppModule, {
23
23
  replicas: 1,
24
24
  },
25
25
  streams: [
26
- {
27
- name: 'agent_inbound',
28
- subjects: ['agent.inbound.>'],
29
- retention: 'workqueue',
30
- },
31
26
  {
32
27
  name: 'agent_events',
33
28
  subjects: ['agent.events.>'],
@@ -12,8 +12,8 @@ export class PublisherController extends BaseController {
12
12
 
13
13
  @Post()
14
14
  async publish(@Body(publishBodySchema) body: PublishBody): Promise<OneBunResponse> {
15
- if (!body.subject.startsWith('agent.events.')) {
16
- return this.error('subject must start with agent.events.', 400, 400);
15
+ if (!body.subject.startsWith('agent.')) {
16
+ return this.error('subject must start with agent.', 400, 400);
17
17
  }
18
18
  await this.publisherService.publish(body.subject, body.payload, body.meta);
19
19
  return this.success({ published: true });
@@ -0,0 +1,68 @@
1
+ import {
2
+ Controller,
3
+ Get,
4
+ Post,
5
+ Delete,
6
+ Body,
7
+ Param,
8
+ Query,
9
+ BaseController,
10
+ UseMiddleware,
11
+ type OneBunResponse,
12
+ } from '@onebun/core';
13
+ import { type } from 'arktype';
14
+ import { RouterService } from './router.service';
15
+ import { ApiKeyMiddleware } from '../auth/api-key.middleware';
16
+
17
+ const createRouteBody = type({
18
+ pattern: 'string',
19
+ 'target?': 'string',
20
+ 'priority?': 'number',
21
+ });
22
+
23
+ type CreateRouteBody = typeof createRouteBody.infer;
24
+
25
+ @Controller('/api/routes')
26
+ @UseMiddleware(ApiKeyMiddleware)
27
+ export class RouterController extends BaseController {
28
+ constructor(private routerService: RouterService) {
29
+ super();
30
+ }
31
+
32
+ @Get('/status')
33
+ async getStatus(): Promise<OneBunResponse> {
34
+ const status = await this.routerService.status();
35
+ return this.success(status);
36
+ }
37
+
38
+ @Get()
39
+ async getRoutes(@Query() query: Record<string, string>): Promise<OneBunResponse> {
40
+ const filters: { pattern?: string; target?: string } = {};
41
+ if (query?.pattern) filters.pattern = query.pattern;
42
+ if (query?.target) filters.target = query.target;
43
+ const routes = await this.routerService.listRoutes(filters);
44
+ return this.success(routes);
45
+ }
46
+
47
+ @Post()
48
+ async createRoute(@Body(createRouteBody) body: CreateRouteBody): Promise<OneBunResponse> {
49
+ if (!body.pattern.startsWith('agent.events.')) {
50
+ return this.error('pattern must start with agent.events.', 400, 400);
51
+ }
52
+ const route = await this.routerService.subscribe(
53
+ body.pattern,
54
+ body.target ?? 'main',
55
+ body.priority ?? 5,
56
+ );
57
+ return this.success(route);
58
+ }
59
+
60
+ @Delete('/:id')
61
+ async deleteRoute(@Param('id') id: string): Promise<OneBunResponse> {
62
+ const deleted = await this.routerService.deleteById(id);
63
+ if (!deleted) {
64
+ return this.error('Route not found', 404, 404);
65
+ }
66
+ return this.success({ deleted: true });
67
+ }
68
+ }
@@ -0,0 +1,11 @@
1
+ import { Module } from '@onebun/core';
2
+ import { RouterRepository } from './router.repository';
3
+ import { RouterService } from './router.service';
4
+ import { RouterController } from './router.controller';
5
+
6
+ @Module({
7
+ controllers: [RouterController],
8
+ providers: [RouterRepository, RouterService],
9
+ exports: [RouterService],
10
+ })
11
+ export class RouterModule {}
@@ -0,0 +1,46 @@
1
+ import { Service, BaseService } from '@onebun/core';
2
+ import { DrizzleService, eq } from '@onebun/drizzle';
3
+ import { eventRoutes, type DbEventRoute, type NewEventRoute } from '../db/schema';
4
+
5
+ @Service()
6
+ export class RouterRepository extends BaseService {
7
+ constructor(private db: DrizzleService) {
8
+ super();
9
+ }
10
+
11
+ async findAll(filters?: { pattern?: string; target?: string }): Promise<DbEventRoute[]> {
12
+ let query = this.db.select().from(eventRoutes);
13
+ if (filters?.pattern) {
14
+ query = query.where(eq(eventRoutes.pattern, filters.pattern)) as any;
15
+ } else if (filters?.target) {
16
+ query = query.where(eq(eventRoutes.target, filters.target)) as any;
17
+ }
18
+ return query.orderBy(eventRoutes.priority) as any;
19
+ }
20
+
21
+ async findEnabled(): Promise<DbEventRoute[]> {
22
+ return this.db.select().from(eventRoutes)
23
+ .where(eq(eventRoutes.enabled, true))
24
+ .orderBy(eventRoutes.priority) as any;
25
+ }
26
+
27
+ async create(route: NewEventRoute): Promise<DbEventRoute> {
28
+ const [created] = await this.db.insert(eventRoutes).values(route).returning();
29
+ return created;
30
+ }
31
+
32
+ async deleteById(id: string): Promise<boolean> {
33
+ const result = await this.db.delete(eventRoutes).where(eq(eventRoutes.id, id)).returning();
34
+ return result.length > 0;
35
+ }
36
+
37
+ async deleteByPattern(pattern: string): Promise<boolean> {
38
+ const result = await this.db.delete(eventRoutes).where(eq(eventRoutes.pattern, pattern)).returning();
39
+ return result.length > 0;
40
+ }
41
+
42
+ async count(): Promise<number> {
43
+ const rows = await this.db.select().from(eventRoutes);
44
+ return rows.length;
45
+ }
46
+ }
@@ -0,0 +1,65 @@
1
+ import { Service, BaseService } from '@onebun/core';
2
+ import { RouterRepository } from './router.repository';
3
+ import type { DbEventRoute } from '../db/schema';
4
+ import { ulid } from 'ulid';
5
+
6
+ @Service()
7
+ export class RouterService extends BaseService {
8
+ constructor(private repo: RouterRepository) {
9
+ super();
10
+ }
11
+
12
+ /** NATS-style pattern matching: exact, * (one level), > (all descendants) */
13
+ matchPattern(pattern: string, subject: string): boolean {
14
+ const patParts = pattern.split('.');
15
+ const subParts = subject.split('.');
16
+
17
+ for (let i = 0; i < patParts.length; i++) {
18
+ if (patParts[i] === '>') {
19
+ return i < subParts.length; // > must match at least one token after
20
+ }
21
+ if (patParts[i] === '*') {
22
+ if (i >= subParts.length) return false;
23
+ continue;
24
+ }
25
+ if (i >= subParts.length || patParts[i] !== subParts[i]) return false;
26
+ }
27
+
28
+ return patParts.length === subParts.length;
29
+ }
30
+
31
+ async findMatchingRoutes(subject: string): Promise<DbEventRoute[]> {
32
+ const routes = await this.repo.findEnabled();
33
+ return routes
34
+ .filter(r => this.matchPattern(r.pattern, subject))
35
+ .sort((a, b) => a.priority - b.priority);
36
+ }
37
+
38
+ async listRoutes(filters?: { pattern?: string; target?: string }): Promise<DbEventRoute[]> {
39
+ return this.repo.findAll(filters);
40
+ }
41
+
42
+ async subscribe(pattern: string, target: string = 'main', priority: number = 5): Promise<DbEventRoute> {
43
+ return this.repo.create({
44
+ id: ulid(),
45
+ pattern,
46
+ target,
47
+ enabled: true,
48
+ priority,
49
+ createdAt: new Date(),
50
+ });
51
+ }
52
+
53
+ async unsubscribe(pattern: string): Promise<boolean> {
54
+ return this.repo.deleteByPattern(pattern);
55
+ }
56
+
57
+ async deleteById(id: string): Promise<boolean> {
58
+ return this.repo.deleteById(id);
59
+ }
60
+
61
+ async status(): Promise<{ configured: boolean; count: number }> {
62
+ const count = await this.repo.count();
63
+ return { configured: count > 0, count };
64
+ }
65
+ }
@@ -0,0 +1,93 @@
1
+ ---
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.
4
+ ---
5
+
6
+ # NATS Event Bus
7
+
8
+ You have access to a NATS JetStream event bus via four tools:
9
+
10
+ | Tool | Purpose |
11
+ |---|---|
12
+ | `nats_publish` | Publish an event (subject + payload) |
13
+ | `nats_subscribe` | Create a routing rule (pattern -> session) |
14
+ | `nats_unsubscribe` | Remove a routing rule by ID |
15
+ | `nats_subscriptions` | List routing rules (optional filters: pattern, target) |
16
+
17
+ ## Bootstrap
18
+
19
+ Before first use, check if subscriptions are configured:
20
+
21
+ 1. Call `nats_subscriptions` with no arguments
22
+ 2. If the list is **non-empty** — setup is complete, proceed with your task
23
+ 3. If **empty** — run initial setup:
24
+
25
+ ```
26
+ nats_subscribe(pattern: "agent.events.cron.>", target: "main")
27
+ nats_subscribe(pattern: "agent.events.subagent.>", target: "main")
28
+ nats_subscribe(pattern: "agent.events.hook.>", target: "main")
29
+ nats_subscribe(pattern: "agent.events.custom.>", target: "main")
30
+ ```
31
+
32
+ Do NOT repeat bootstrap if subscriptions already exist.
33
+
34
+ ## Event-Driven Rules
35
+
36
+ 1. **Never poll** — subscribe to events and react when they arrive as messages in your session
37
+ 2. **Cron is OK** but must go through NATS: cron job calls `nats-cron-trigger.sh` -> event published -> delivered to your session -> you react
38
+ 3. **Every scheduled task** = cron entry calling `nats-cron-trigger.sh` + subscription on that subject
39
+ 4. **Incoming events** arrive as chat messages formatted `[NATS:<subject>] <payload>`
40
+
41
+ ## Creating Cron Tasks
42
+
43
+ Always use the bundled script — never invoke LLM from cron:
44
+
45
+ ```bash
46
+ # Step 1: Subscribe to the event
47
+ nats_subscribe(pattern: "agent.events.cron.daily-report", target: "main")
48
+
49
+ # Step 2: Create cron entry using the trigger script
50
+ # The script is at: <plugin-dir>/skills/nats-events/scripts/nats-cron-trigger.sh
51
+ */30 * * * * /path/to/nats-cron-trigger.sh agent.events.cron.check-revenue '{"task":"check_revenue"}'
52
+ 0 9 * * * /path/to/nats-cron-trigger.sh agent.events.cron.daily-report '{"task":"daily_report"}'
53
+ ```
54
+
55
+ The script only needs `NATS_SIDECAR_URL` and `NATS_PLUGIN_API_KEY` environment variables.
56
+
57
+ ## Subject Hierarchy
58
+
59
+ | Pattern | Use for |
60
+ |---|---|
61
+ | `agent.events.cron.*` | Scheduled task triggers |
62
+ | `agent.events.subagent.spawned` | Subagent started |
63
+ | `agent.events.subagent.ended` | Subagent completed |
64
+ | `agent.events.hook.*` | External webhook triggers |
65
+ | `agent.events.session.*` | Session lifecycle |
66
+ | `agent.events.tool.*` | Tool execution results |
67
+ | `agent.events.gateway.*` | Gateway startup/restart |
68
+ | `agent.events.custom.*` | Your custom events |
69
+
70
+ ## Pattern Matching
71
+
72
+ - Exact: `agent.events.cron.daily-report` — matches only this subject
73
+ - `*` — one level: `agent.events.cron.*` matches `agent.events.cron.daily` but not `agent.events.cron.reports.weekly`
74
+ - `>` — all descendants: `agent.events.cron.>` matches everything under `agent.events.cron.`
75
+
76
+ ## Examples
77
+
78
+ **React to subagent completion:**
79
+ ```
80
+ nats_subscribe(pattern: "agent.events.subagent.ended", target: "main")
81
+ # When subagent finishes, you receive: [NATS:agent.events.subagent.ended] {"subagentId":...,"result":...}
82
+ ```
83
+
84
+ **Publish a custom event for external consumers:**
85
+ ```
86
+ nats_publish(subject: "agent.events.custom.report-ready", payload: {"reportUrl": "https://..."})
87
+ ```
88
+
89
+ **Schedule a recurring task:**
90
+ ```
91
+ nats_subscribe(pattern: "agent.events.cron.hourly-check", target: "main")
92
+ # Then create crontab: 0 * * * * nats-cron-trigger.sh agent.events.cron.hourly-check '{}'
93
+ ```
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env bash
2
+ # nats-cron-trigger.sh — Publish a NATS event from cron (no LLM involved).
3
+ #
4
+ # Usage:
5
+ # nats-cron-trigger.sh <subject> [payload_json]
6
+ #
7
+ # Examples:
8
+ # nats-cron-trigger.sh agent.events.cron.daily-report
9
+ # nats-cron-trigger.sh agent.events.cron.check-revenue '{"task":"check_revenue"}'
10
+ #
11
+ # Environment:
12
+ # NATS_SIDECAR_URL — Sidecar HTTP URL (default: http://127.0.0.1:3104)
13
+ # NATS_PLUGIN_API_KEY — Bearer token (required)
14
+
15
+ set -euo pipefail
16
+
17
+ SUBJECT="${1:?Usage: nats-cron-trigger.sh <subject> [payload_json]}"
18
+ PAYLOAD="${2:-"{}"}"
19
+ SIDECAR="${NATS_SIDECAR_URL:-http://127.0.0.1:3104}"
20
+
21
+ if [ -z "${NATS_PLUGIN_API_KEY:-}" ]; then
22
+ echo "Error: NATS_PLUGIN_API_KEY is not set" >&2
23
+ exit 1
24
+ fi
25
+
26
+ exec curl -sf -X POST "${SIDECAR}/api/publish" \
27
+ -H "Authorization: Bearer ${NATS_PLUGIN_API_KEY}" \
28
+ -H "Content-Type: application/json" \
29
+ -d "{\"subject\":\"${SUBJECT}\",\"payload\":${PAYLOAD}}"