@zooid/core 0.13.0 → 0.14.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.
@@ -4,7 +4,9 @@ import type {
4
4
  HistoryPage,
5
5
  Message,
6
6
  Member,
7
- ChannelInfo,
7
+ RoomInfo,
8
+ SendMessageInput,
9
+ SendMessageResult,
8
10
  ThreadOverviewPage,
9
11
  } from './transport-context.js'
10
12
 
@@ -20,11 +22,16 @@ describe('TransportContextProvider', () => {
20
22
  >()
21
23
  expectTypeOf<TransportContextProvider['getThreadHistory']>().returns.resolves.toEqualTypeOf<HistoryPage>()
22
24
  expectTypeOf<TransportContextProvider['getChannelMembers']>().returns.resolves.toEqualTypeOf<Member[]>()
23
- expectTypeOf<TransportContextProvider['getChannelInfo']>().returns.resolves.toEqualTypeOf<ChannelInfo>()
25
+ expectTypeOf<TransportContextProvider['getRoomInfo']>().returns.resolves.toEqualTypeOf<RoomInfo>()
26
+ expectTypeOf<TransportContextProvider['getRooms']>().returns.resolves.toEqualTypeOf<RoomInfo[]>()
27
+ expectTypeOf<TransportContextProvider['sendMessage']>().parameters.toEqualTypeOf<
28
+ [SendMessageInput]
29
+ >()
30
+ expectTypeOf<TransportContextProvider['sendMessage']>().returns.resolves.toEqualTypeOf<SendMessageResult>()
24
31
  })
25
32
 
26
- it('ChannelInfo.transport is the two-transport MVP union', () => {
27
- expectTypeOf<ChannelInfo['transport']>().toEqualTypeOf<'http' | 'matrix'>()
33
+ it('RoomInfo.transport is the two-transport MVP union', () => {
34
+ expectTypeOf<RoomInfo['transport']>().toEqualTypeOf<'http' | 'matrix'>()
28
35
  })
29
36
 
30
37
  it('Message and Member carry the is_agent flag', () => {
@@ -27,12 +27,24 @@ export interface Member {
27
27
  agent_name?: string
28
28
  }
29
29
 
30
- export interface ChannelInfo {
30
+ /** Renamed from ChannelInfo — "channel" is not a Matrix primitive ([[ZOD084]]). */
31
+ export interface RoomInfo {
31
32
  id: string
32
33
  name: string
33
34
  transport: 'http' | 'matrix'
34
35
  }
35
36
 
37
+ export interface SendMessageInput {
38
+ room: string
39
+ thread_id?: string
40
+ text: string
41
+ }
42
+
43
+ export interface SendMessageResult {
44
+ event_id: string
45
+ thread_id?: string
46
+ }
47
+
36
48
  export interface HistoryPage {
37
49
  messages: Message[]
38
50
  next_before?: string
@@ -87,5 +99,9 @@ export interface TransportContextProvider {
87
99
  opts: HistoryOptions,
88
100
  ): Promise<HistoryPage>
89
101
  getChannelMembers(channelId: string): Promise<Member[]>
90
- getChannelInfo(channelId: string): Promise<ChannelInfo>
102
+ /** Renamed from getChannelInfo. */
103
+ getRoomInfo(channelId: string): Promise<RoomInfo>
104
+ /** Rooms this agent is bound to. Targets for sendMessage. */
105
+ getRooms(): Promise<RoomInfo[]>
106
+ sendMessage(input: SendMessageInput): Promise<SendMessageResult>
91
107
  }
@@ -0,0 +1,168 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { loadZooidConfig } from './config.js'
3
+
4
+ const base = `
5
+ runtime: local
6
+ transports:
7
+ matrix:
8
+ type: matrix
9
+ homeserver: http://localhost:8448
10
+ as_token: t
11
+ hs_token: h
12
+ user_namespace: '@.*:example.org'
13
+ agents:
14
+ architect:
15
+ acp: { preset: opencode }
16
+ matrix:
17
+ rooms: ["#ops:example.org"]
18
+ `
19
+
20
+ const baseTwoAgents = `
21
+ runtime: local
22
+ transports:
23
+ matrix:
24
+ type: matrix
25
+ homeserver: http://localhost:8448
26
+ as_token: t
27
+ hs_token: h
28
+ user_namespace: '@.*:example.org'
29
+ agents:
30
+ architect:
31
+ acp: { preset: opencode }
32
+ matrix:
33
+ rooms: ["#ops:example.org"]
34
+ product-manager:
35
+ acp: { preset: opencode }
36
+ matrix:
37
+ rooms: ["#ops:example.org"]
38
+ `
39
+
40
+ const baseWithHttpAgent = `
41
+ runtime: local
42
+ transports:
43
+ matrix:
44
+ type: matrix
45
+ homeserver: http://localhost:8448
46
+ as_token: t
47
+ hs_token: h
48
+ user_namespace: '@.*:example.org'
49
+ http-local:
50
+ type: http
51
+ port: 8080
52
+ agents:
53
+ architect:
54
+ acp: { preset: opencode }
55
+ matrix:
56
+ rooms: ["#ops:example.org"]
57
+ worker:
58
+ acp: { preset: opencode }
59
+ http: {}
60
+ `
61
+
62
+ const withTriggers = (block: string) => `${base}\ntriggers:\n${block}`
63
+
64
+ const ok = `
65
+ image-currency:
66
+ schedule: "0 6 * * 1"
67
+ as: "@cron:example.org"
68
+ room: "#ops:example.org"
69
+ mention: architect
70
+ text: "Check the pinned agent CLI versions."
71
+ `
72
+
73
+ describe('triggers: config', () => {
74
+ it('is optional — a config with no triggers loads and yields an empty map', () => {
75
+ expect(loadZooidConfig(base).triggers).toEqual({})
76
+ })
77
+
78
+ it('parses a schedule trigger', () => {
79
+ const t = loadZooidConfig(withTriggers(ok)).triggers['image-currency']
80
+ expect(t).toEqual({
81
+ schedule: '0 6 * * 1',
82
+ as: '@cron:example.org',
83
+ messages: [
84
+ { room: '#ops:example.org', mention: 'architect', text: 'Check the pinned agent CLI versions.' },
85
+ ],
86
+ })
87
+ })
88
+
89
+ it('accepts a room id as well as an alias', () => {
90
+ const cfg = loadZooidConfig(withTriggers(ok.replace('"#ops:example.org"', '"!abc:example.org"')))
91
+ expect(cfg.triggers['image-currency'].messages[0].room).toBe('!abc:example.org')
92
+ })
93
+
94
+ it('rejects run: — the emitter tier was dropped, and a stale config must fail loudly', () => {
95
+ expect(() =>
96
+ loadZooidConfig(withTriggers(`${ok} run: "echo hi"\n`)),
97
+ ).toThrow(/triggers\.image-currency\.run: is not supported/)
98
+ })
99
+
100
+ it('rejects a mention that names no configured agent', () => {
101
+ expect(() =>
102
+ loadZooidConfig(withTriggers(ok.replace('mention: architect', 'mention: nobody'))),
103
+ ).toThrow(/triggers\.image-currency\.messages\[0\]\.mention: unknown agent "nobody"/)
104
+ })
105
+
106
+ it('rejects an invalid cron expression at load time, not at first fire', () => {
107
+ expect(() =>
108
+ loadZooidConfig(withTriggers(ok.replace('"0 6 * * 1"', '"not a cron"'))),
109
+ ).toThrow(/triggers\.image-currency\.schedule/)
110
+ })
111
+
112
+ it('expands a bare localpart in as: to a full MXID via the sole matrix transport', () => {
113
+ const cfg = loadZooidConfig(withTriggers(ok.replace('"@cron:example.org"', '"cron"')))
114
+ expect(cfg.triggers['image-currency'].as).toBe('@cron:example.org')
115
+ })
116
+
117
+ it('expands a short-form @localpart in as: the same way', () => {
118
+ const cfg = loadZooidConfig(withTriggers(ok.replace('"@cron:example.org"', '"@cron"')))
119
+ expect(cfg.triggers['image-currency'].as).toBe('@cron:example.org')
120
+ })
121
+
122
+ it('rejects an as: with invalid localpart characters', () => {
123
+ expect(() =>
124
+ loadZooidConfig(withTriggers(ok.replace('"@cron:example.org"', '"Not Valid!"'))),
125
+ ).toThrow(/triggers\.image-currency\.as: must be a full MXID/)
126
+ })
127
+
128
+ it("rejects an as: that resolves to the mentioned agent's own MXID — a self-post never routes back", () => {
129
+ const block = ok.replace('"@cron:example.org"', '"architect"')
130
+ expect(() => loadZooidConfig(withTriggers(block))).toThrow(
131
+ /triggers\.image-currency\.as: must not equal the mentioned agent/,
132
+ )
133
+ })
134
+
135
+ it("allows as: to impersonate a different agent's own identity — cron-as-a-role", () => {
136
+ const block = ok.replace('"@cron:example.org"', '"product-manager"')
137
+ const cfg = loadZooidConfig(`${baseTwoAgents}\ntriggers:\n${block}`)
138
+ expect(cfg.triggers['image-currency'].as).toBe('@product-manager:example.org')
139
+ })
140
+
141
+ it('rejects mention: of an agent with no matrix: binding', () => {
142
+ const block = ok.replace('mention: architect', 'mention: worker')
143
+ expect(() => loadZooidConfig(`${baseWithHttpAgent}\ntriggers:\n${block}`)).toThrow(
144
+ /triggers\.image-currency\.messages\[0\]\.mention: agent "worker" has no matrix: binding/,
145
+ )
146
+ })
147
+
148
+ it('requires as, room, mention and text', () => {
149
+ const indexed = new Set(['room', 'mention', 'text'])
150
+ for (const field of ['as', 'room', 'mention', 'text']) {
151
+ const block = ok
152
+ .split('\n')
153
+ .filter((l) => !l.trim().startsWith(`${field}:`))
154
+ .join('\n')
155
+ const label = indexed.has(field)
156
+ ? `triggers\\.image-currency\\.messages\\[0\\]\\.${field}`
157
+ : `triggers\\.image-currency\\.${field}`
158
+ expect(() => loadZooidConfig(withTriggers(block))).toThrow(new RegExp(label))
159
+ }
160
+ })
161
+
162
+ it('rejects a trigger with no schedule', () => {
163
+ const block = ok.split('\n').filter((l) => !l.trim().startsWith('schedule:')).join('\n')
164
+ expect(() => loadZooidConfig(withTriggers(block))).toThrow(
165
+ /triggers\.image-currency: must specify schedule:/,
166
+ )
167
+ })
168
+ })
@@ -0,0 +1,146 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { loadZooidConfig } from './config.js'
3
+
4
+ const head = `
5
+ runtime: local
6
+ transports:
7
+ matrix:
8
+ type: matrix
9
+ homeserver: http://localhost:8448
10
+ as_token: t
11
+ hs_token: h
12
+ user_namespace: '@.*:example.org'
13
+ agents:
14
+ product:
15
+ acp: { preset: opencode }
16
+ matrix:
17
+ rooms: ["#product:example.org"]
18
+ architect:
19
+ acp: { preset: opencode }
20
+ matrix:
21
+ rooms: ["#dev:example.org"]
22
+ `
23
+
24
+ const flat = `${head}
25
+ triggers:
26
+ triage:
27
+ webhook:
28
+ provider: github
29
+ secret: "s"
30
+ as: "@hook:example.org"
31
+ room: "#product:example.org"
32
+ mention: product
33
+ match: 'event == "issues" && body.action == "opened"'
34
+ text: 'Triage \${body.repository.full_name}#\${body.issue.number}.'
35
+ `
36
+
37
+ const plural = `${head}
38
+ triggers:
39
+ github:
40
+ webhook:
41
+ provider: github
42
+ secret: "s"
43
+ as: "@hook:example.org"
44
+ messages:
45
+ - room: "#product:example.org"
46
+ mention: product
47
+ match: 'event == "issues" && body.action == "opened"'
48
+ text: 'Triage it.'
49
+ - room: "#dev:example.org"
50
+ mention: architect
51
+ match: 'event == "pull_request" && body.action == "closed"'
52
+ text: 'Reconcile it.'
53
+ `
54
+
55
+ describe('flat form', () => {
56
+ it('desugars into a single-entry messages list', () => {
57
+ const t = loadZooidConfig(flat).triggers.triage
58
+ expect(t.messages).toHaveLength(1)
59
+ expect(t.messages[0].room).toBe('#product:example.org')
60
+ expect(t.messages[0].mention).toBe('product')
61
+ expect(t.messages[0].match).toBe('event == "issues" && body.action == "opened"')
62
+ })
63
+
64
+ // Nothing downstream may branch on which spelling was used.
65
+ it('leaves no flat room/mention/text on the parsed trigger', () => {
66
+ const t = loadZooidConfig(flat).triggers.triage as Record<string, unknown>
67
+ expect(t.room).toBeUndefined()
68
+ expect(t.mention).toBeUndefined()
69
+ expect(t.text).toBeUndefined()
70
+ })
71
+ })
72
+
73
+ describe('messages form', () => {
74
+ it('parses several messages', () => {
75
+ const t = loadZooidConfig(plural).triggers.github
76
+ expect(t.messages.map((m) => m.mention)).toEqual(['product', 'architect'])
77
+ })
78
+
79
+ it('exposes every declared room, so the bot pool can join them at start', () => {
80
+ const t = loadZooidConfig(plural).triggers.github
81
+ expect(t.messages.map((m) => m.room)).toEqual(['#product:example.org', '#dev:example.org'])
82
+ })
83
+
84
+ it('validates each message the same way the flat form is validated', () => {
85
+ expect(() => loadZooidConfig(plural.replace('mention: architect', 'mention: nobody'))).toThrow(
86
+ /triggers\.github\.messages\[1\]\.mention: unknown agent "nobody"/,
87
+ )
88
+ })
89
+
90
+ it('rejects an empty messages list', () => {
91
+ expect(() => loadZooidConfig(`${head}
92
+ triggers:
93
+ github:
94
+ webhook: { provider: github, secret: "s" }
95
+ as: "@hook:example.org"
96
+ messages: []
97
+ `)).toThrow(/triggers\.github\.messages: must not be empty/)
98
+ })
99
+ })
100
+
101
+ describe('mutual exclusion and validation', () => {
102
+ it('rejects flat keys together with messages, rather than picking one silently', () => {
103
+ expect(() => loadZooidConfig(plural.replace(' messages:', ' room: "#product:example.org"\n messages:'))).toThrow(
104
+ /triggers\.github: specify either room:\/mention:\/text: or messages:, not both/,
105
+ )
106
+ })
107
+
108
+ it('rejects match: on a schedule trigger — there is no payload to evaluate', () => {
109
+ expect(() => loadZooidConfig(`${head}
110
+ triggers:
111
+ weekly:
112
+ schedule: "0 6 * * 1"
113
+ as: "@cron:example.org"
114
+ room: "#dev:example.org"
115
+ mention: architect
116
+ match: 'true'
117
+ text: 'go'
118
+ `)).toThrow(/triggers\.weekly\.match: only applies to a webhook: trigger/)
119
+ })
120
+
121
+ it('allows messages: on a schedule trigger — one cron, several agents', () => {
122
+ const cfg = loadZooidConfig(`${head}
123
+ triggers:
124
+ weekly:
125
+ schedule: "0 6 * * 1"
126
+ as: "@cron:example.org"
127
+ messages:
128
+ - { room: "#product:example.org", mention: product, text: 'a' }
129
+ - { room: "#dev:example.org", mention: architect, text: 'b' }
130
+ `)
131
+ expect(cfg.triggers.weekly.messages).toHaveLength(2)
132
+ })
133
+
134
+ // A typo must fail the daemon at boot, not silently never match.
135
+ it('rejects a malformed match expression at config load', () => {
136
+ expect(() => loadZooidConfig(flat.replace("match: 'event == \"issues\" && body.action == \"opened\"'", "match: 'body.action =='"))).toThrow(
137
+ /triggers\.triage\.messages\[0\]\.match: /,
138
+ )
139
+ })
140
+
141
+ it('no longer accepts event:', () => {
142
+ expect(() => loadZooidConfig(flat.replace(' secret: "s"', ' event: issues\n secret: "s"'))).toThrow(
143
+ /triggers\.triage\.webhook\.event: no longer supported — use match:/,
144
+ )
145
+ })
146
+ })
@@ -0,0 +1,143 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { loadZooidConfig } from './config.js'
3
+
4
+ const base = `
5
+ runtime: local
6
+ transports:
7
+ matrix:
8
+ type: matrix
9
+ homeserver: http://localhost:8448
10
+ as_token: t
11
+ hs_token: h
12
+ user_namespace: '@.*:example.org'
13
+ agents:
14
+ product:
15
+ acp: { preset: opencode }
16
+ matrix:
17
+ rooms: ["#product:example.org"]
18
+ triggers:
19
+ reconcile:
20
+ webhook:
21
+ provider: github
22
+ secret: "topsecret"
23
+ as: "@hook:example.org"
24
+ room: "#product:example.org"
25
+ mention: product
26
+ text: |
27
+ A PR merged.
28
+
29
+ \${output}
30
+ `
31
+
32
+ describe('webhook triggers: config', () => {
33
+ it('parses a webhook trigger', () => {
34
+ const t = loadZooidConfig(base).triggers.reconcile
35
+ expect(t.webhook).toEqual({ provider: 'github', secret: 'topsecret' })
36
+ expect(t.schedule).toBeUndefined()
37
+ })
38
+
39
+ it('no longer accepts event: — use match: instead', () => {
40
+ expect(() =>
41
+ loadZooidConfig(base.replace('provider: github', 'provider: github\n event: pull_request')),
42
+ ).toThrow(/triggers\.reconcile\.webhook\.event: no longer supported — use match:/)
43
+ })
44
+
45
+ it('rejects a trigger with both schedule and webhook', () => {
46
+ expect(() =>
47
+ loadZooidConfig(base.replace(' as: "@hook', ' schedule: "0 6 * * 1"\n as: "@hook')),
48
+ ).toThrow(/triggers\.reconcile: specify either schedule: or webhook:, not both/)
49
+ })
50
+
51
+ it('rejects an unknown provider — per-provider verification must exist', () => {
52
+ expect(() => loadZooidConfig(base.replace('provider: github', 'provider: acme'))).toThrow(
53
+ /triggers\.reconcile\.webhook\.provider: unknown provider "acme"/,
54
+ )
55
+ })
56
+
57
+ it('requires a secret — fail closed, never "no secret configured so accept"', () => {
58
+ expect(() =>
59
+ loadZooidConfig(base.split('\n').filter((l) => !l.includes('secret:')).join('\n')),
60
+ ).toThrow(/triggers\.reconcile\.webhook\.secret: is required/)
61
+ })
62
+
63
+ it('interpolates the secret from the environment', () => {
64
+ process.env.TEST_WH_SECRET = 'from-env'
65
+ try {
66
+ const cfg = loadZooidConfig(base.replace('"topsecret"', '${TEST_WH_SECRET}'))
67
+ expect(cfg.triggers.reconcile.webhook?.secret).toBe('from-env')
68
+ } finally {
69
+ delete process.env.TEST_WH_SECRET
70
+ }
71
+ })
72
+ })
73
+
74
+ /**
75
+ * `custom` is the escape hatch for any signing scheme without a named
76
+ * provider — ed25519, SHA-1 over sorted params, bespoke timestamped base
77
+ * strings. It takes a function rather than declarative fields, because a
78
+ * config language for signing schemes is a mini-DSL that still would not
79
+ * cover them all.
80
+ */
81
+ describe('webhook triggers: the custom provider', () => {
82
+ const custom = (block: string) =>
83
+ base.replace(
84
+ ` provider: github
85
+ secret: "topsecret"`,
86
+ block,
87
+ )
88
+
89
+ it('resolves the verifier path against the zooid.yaml directory', () => {
90
+ const t = loadZooidConfig(
91
+ custom(` provider: custom
92
+ verify: ./verifiers/shopify.js
93
+ secret: "topsecret"`),
94
+ { configDir: '/srv/workforce' },
95
+ ).triggers.reconcile
96
+ expect(t.webhook).toEqual({
97
+ provider: 'custom',
98
+ verify: '/srv/workforce/verifiers/shopify.js',
99
+ secret: 'topsecret',
100
+ })
101
+ })
102
+
103
+ it('leaves an absolute verifier path alone', () => {
104
+ const t = loadZooidConfig(
105
+ custom(` provider: custom
106
+ verify: /opt/zooid/verify.js
107
+ secret: "topsecret"`),
108
+ { configDir: '/srv/workforce' },
109
+ ).triggers.reconcile
110
+ expect(t.webhook?.verify).toBe('/opt/zooid/verify.js')
111
+ })
112
+
113
+ it('requires verify: — there is no built-in scheme to fall back on', () => {
114
+ expect(() =>
115
+ loadZooidConfig(
116
+ custom(` provider: custom
117
+ secret: "topsecret"`),
118
+ { configDir: '/srv/workforce' },
119
+ ),
120
+ ).toThrow(/triggers\.reconcile\.webhook\.verify: is required when provider: custom/)
121
+ })
122
+
123
+ it('rejects a relative verifier path with no configDir to resolve against', () => {
124
+ expect(() =>
125
+ loadZooidConfig(
126
+ custom(` provider: custom
127
+ verify: ./verify.js
128
+ secret: "topsecret"`),
129
+ ),
130
+ ).toThrow(/triggers\.reconcile\.webhook\.verify: relative path/)
131
+ })
132
+
133
+ it('rejects verify: on a named provider, rather than ignoring it', () => {
134
+ expect(() =>
135
+ loadZooidConfig(
136
+ custom(` provider: github
137
+ verify: ./verify.js
138
+ secret: "topsecret"`),
139
+ { configDir: '/srv/workforce' },
140
+ ),
141
+ ).toThrow(/triggers\.reconcile\.webhook\.verify: only applies to provider: custom/)
142
+ })
143
+ })
package/src/types.ts CHANGED
@@ -277,6 +277,8 @@ export interface ZooidConfig {
277
277
  pre_turn?: string
278
278
  post_turn?: string
279
279
  }
280
+ /** Optional. Map of trigger name → trigger. Empty map when the block is absent. */
281
+ triggers: Record<string, TriggerConfig>
280
282
  }
281
283
 
282
284
  export interface CliFlags {
@@ -284,3 +286,66 @@ export interface CliFlags {
284
286
  /** Container image override (shorthand for container.image). */
285
287
  image?: string
286
288
  }
289
+
290
+ /**
291
+ * A webhook trigger: exposes `POST /_zooid/webhooks/<name>`, verifies an HMAC
292
+ * signature over the raw request body, and fires like a schedule trigger
293
+ * once accepted. See [[ZOD082]] §Design 4 and [[ZOD086]].
294
+ */
295
+ export interface WebhookTriggerConfig {
296
+ provider: 'github' | 'stripe' | 'slack' | 'standard' | 'custom'
297
+ /** Required. Interpolated from env — see [[ZOD081]] §Design 4, Secrets. */
298
+ secret: string
299
+ /**
300
+ * `custom` only, required: path to a module exporting the verifier
301
+ * function, resolved to an absolute path against the zooid.yaml directory
302
+ * at parse time. The escape hatch for any scheme without a named provider
303
+ * — ed25519, SHA-1 over sorted params, bespoke timestamped base strings.
304
+ * Code rather than declarative fields, because a config language for
305
+ * signing schemes is a security-critical mini-DSL that still would not
306
+ * cover them all. The daemon imports it at startup — see
307
+ * `loadCustomVerifiers` in the CLI.
308
+ */
309
+ verify?: string
310
+ }
311
+
312
+ /**
313
+ * One message a trigger can post: a room, the agent to mention, the body
314
+ * template, and — webhook triggers only — a CEL `match:` predicate gating
315
+ * whether this message fires for a given delivery. See [[ZOD085]] §Design 4.
316
+ */
317
+ export interface TriggerMessage {
318
+ /** Room id or alias the message goes to. */
319
+ room: string
320
+ /** Agent name (key in `agents`) to mention. Structural, never templated — §Design 3. */
321
+ mention: string
322
+ /** The message body. Literal for a scheduled trigger; `${...}` is CEL-interpolated for a webhook trigger. */
323
+ text: string
324
+ /**
325
+ * CEL predicate over the delivery (`event`, `body`, `headers`, `output`).
326
+ * Absent means always fire. Only valid on a `webhook:` trigger — a
327
+ * schedule trigger has no delivery to evaluate.
328
+ */
329
+ match?: string
330
+ }
331
+
332
+ /**
333
+ * A trigger: fires on a cron or a webhook delivery and posts one or more
334
+ * `messages`, each structurally mentioning its agent so the turn starts
335
+ * through the ordinary message path. No emitter/command tier — see
336
+ * [[ZOD081]] §Concept.
337
+ */
338
+ export interface TriggerConfig {
339
+ /** Cron expression. Mutually exclusive with `webhook:` — exactly one is required. */
340
+ schedule?: string
341
+ /** Webhook ingress config. Mutually exclusive with `schedule:` — [[ZOD082]]. */
342
+ webhook?: WebhookTriggerConfig
343
+ /** Full MXID the trigger posts as, e.g. `@cron:example.org`. */
344
+ as: string
345
+ /**
346
+ * The messages this trigger can post. A flat `room:`/`mention:`/`text:`/
347
+ * `match:` at the trigger level desugars into a single-entry list here at
348
+ * config load — nothing downstream branches on which spelling was used.
349
+ */
350
+ messages: TriggerMessage[]
351
+ }