Skip to content

Triggers

New in 1.3.0

The trigger system is available from v1.3.0. In v1.2.0 and earlier, workflows and agent conversations could only be invoked manually.

What triggers are: a connector between "events that happen in the system" and "actions to perform". Events can be a cron schedule, a webhook arriving, a channel message, an employee finishing a conversation, or another workflow completing. Actions are either starting a workflow or sending a message to an employee for processing.

What triggers are not:

  • Not a replacement cron-job manager — mate_cron_job still exists and runs independently; triggers share its ShedLock + scheduler base but do not write into mate_cron_job
  • Not an IFTTT / n8n drag-to-edit automation builder — triggers only do "event → action" routing; complex logic belongs in Workflow
  • Not a full-feature webhook dispatcher — they handle dedup / rate-limit / bot-self filtering / pattern matching, not arbitrary business-payload parsing

v1.3.0 scope

v0 = 6 pattern types + 2 dispatch targets (agent / workflow). Event governance (dedup, per-trigger rate limit, recursion guard, bot-self filtering) is on by default.


One-minute overview

jsonc
// A trigger that runs a "morning report" workflow daily at 9 AM
{
  "name": "daily-morning-report",
  "patternType": "cron",
  "patternJson": {
    "cronExpression": "0 0 9 * * *",
    "timezone": "Asia/Shanghai"
  },
  "targetType": "workflow",
  "targetId": 12345,
  "payloadTemplate": "{ \"date\": \"{{ now | date('yyyy-MM-dd') }}\" }",
  "rateLimitPerMin": 10,
  "dedupWindowSecs": 60,
  "botSelfFilter": true,
  "enabled": true
}

At 9 AM → backend grabs the ShedLock via CronDelegationPort → renders the payload → enqueues an async run of workflow 12345. Other instances at the same moment are blocked by the lock; no double-fire.


Six pattern types

Implemented in TriggerPatternMatcher.java. Each pattern matches its pattern_json block on the trigger row. Fields not listed here are ignored by v0's matcher.

PatternWhen it firespattern_json fieldsReuse constraint
cronOn a cron expression (does not flow through ingest; runs from the scheduler)cronExpression, timezoneReuses the cron/ module's ShedLock + Spring TaskScheduler; does NOT write into mate_cron_job, does NOT call CronJobService
webhookGeneric event passthrough (v0 does no further filtering — secret check happens at the channel layer; the trigger itself just matches patternType=webhook)(none in v0)Through the unified POST /api/v1/triggers/events entry + envelope wrap
channel_messageChannel receives a messagechannelType (optional, compared against envelope data.channelType), senderEquals (optional, exact sender id match)Side-channel through ChannelWebhookController; original routing unaffected
agent_lifecycleAgent lifecycle eventsagentId (optional), phase (optional: spawned / terminated / crashed)Hangs off ReActLifecycleListener
content_matchSubstring must appear in the envelope contentsubstring (required, case-insensitive contains-match against envelope data.content)Generic content filter; the event source is whatever fed the envelope
workflow_completionA workflow run reaches a terminal statesourceWorkflowId (optional), stateFilter (optional: completed / failed / any)Listens to WorkflowEngine terminal events; recursion guard below

Unknown pattern types fail closed by default — typo'd or future pattern types can't silently fire every trigger in the workspace.

Not in v1.3.0: schedule (one-shot non-cron like "30 minutes from now"), external MQ listeners (Kafka / Pulsar / RocketMQ), metrics / threshold alerting triggers.


Event governance (on by default)

Bot self-msg filtering (default binding is no-op)

Some channels (Feishu / DingTalk / WeCom) surface bot-emitted messages back as channel_message events. The framework wires this through BotSelfFilter SPI + each trigger's bot_self_filter field (default true).

v0 default implementation is no-op

The default-bound NoopBotSelfFilter returns false from isBotSelf(...) for every sender. That means bot_self_filter=true on a trigger doesn't actually filter anything in v0 until a channel adapter registers a real BotSelfFilter Spring Bean (which replaces the default). This is intentional — a wrong default would silently swallow all legitimate bot-to-bot messages.

To exempt a single trigger from the framework filter (rare — e.g. a bot emitting a special command to trigger cleanup), set that trigger's bot_self_filter to false.

Event dedup

When TriggerEventIngestService dispatches an event, the engine queries mate_trigger_event for the dedup_key within the dedupWindowSecs window (default 60s). Already present → dropped, fire_count not incremented.

Default dedupWindowSecs = 60. Raise it to absorb longer gateway re-deliveries; set to 0 to disable (not recommended).

Per-trigger rate limit

Each trigger is rate-limited individually: at most rateLimitPerMin per minute (default 10). Events past the cap are dropped — no retry, no row in mate_trigger_event; instead mate_trigger.last_error is updated to "rate-limited" so ops can see it.

channel_message triggers usually want this raised (group bursts); workflow_completion triggers usually want it lowered (to slow A→B→A chains).

Recursion guard

A workflow_completion trigger fires a workflow which fires another workflow_completion… dispatch chain length > 5 → engine cuts + alerts. Intended to break "A writes a message that triggers B, B writes a message that triggers A" loops.

Webhook ACK timing

The HTTP entry (POST /api/v1/triggers/events) → envelope wrap → dedup check → bot-self check → rate-limit check → immediate 200 ACK → async dispatch. Implications:

  • Upstream gateways (Feishu / DingTalk etc.) get 200 and stop re-delivering
  • Dispatch failures → mate_trigger.last_error updates; same dedup_key on retry is still dedup'd (no automatic retry)

"ACK only after dispatch succeeds" semantics — not in v0 — fire-and-forget is intentional for surge handling.


Managing triggers from the UI

1.4.0 change: merged into the Scheduler

As of v1.4.0, Scheduled Jobs and Triggers are merged into a single Scheduler page (Settings → Scheduler, route /settings/scheduler) with three tabs: Scheduled Jobs / Event Triggers / Run History. Each tab shows an item count next to its title; the top-right action button is context-aware (it's "New" on the Scheduled Jobs / Event Triggers tabs, "Refresh" on the History tab); Run History spans both — execution records for both scheduled jobs and triggers live here.

The old routes redirect automatically: /cron-jobs and /settings/triggers each land on the matching Scheduler tab.

Entry point

Settings → Scheduler (sidebar) → Event Triggers tab. In v1.4.0 the trigger list was redesigned from the old wide table into rule cards — one card per trigger, showing pattern type / target / enabled state at a glance. Click + New Trigger to open the drawer.

Creating a trigger

The drawer has structured forms per pattern type — no hand-written pattern_json:

  • cron → cron expression input + timezone dropdown + next-fire preview. The expression can be typed by hand, or click the edit button beside the input to open the visual cron editor (see below)
  • channel_message → channel type (optional) + sender id exact-match (optional)
  • agent_lifecycle → agent (optional) + phase: spawned / terminated / crashed (optional)
  • content_match → substring (required), matched case-insensitively against envelope data.content
  • workflow_completion → upstream workflow (optional) + state filter: completed / failed / any (optional)
  • webhook → no extra fields in v0 (transparent passthrough)

Save → trigger persists; with enabled=true it's registered with the right engine immediately (cron → ShedLock; others → envelope router).

Visual cron editor (new in 1.4.0)

You don't have to hand-write the cron expression. Click the edit button beside the expression input to open a segmented editor: minute / hour / day / month / day-of-week each get a tab, and each segment offers "every / specific value / range / step"; a row of presets up top (every minute, on the hour, daily at midnight, every Monday…) fills it in with one click; at the bottom is a live human-readable preview that translates the current expression into plain language (e.g. "every day at 09:00").

This editor is the same component shared by Scheduled Jobs and Triggers:

  • Scheduled Jobs use 5-field cron (minute hour day month day-of-week)
  • Triggers use 6-field cron (with seconds: second minute hour day month day-of-week) — an extra leading seconds field

The input itself also carries a one-line readable preview, so you can confirm what your hand-typed expression parsed to without opening the editor.


Scheduled-task types (task type)

Every job on the Scheduled Jobs tab of the Scheduler has a task_type that decides what it does when it runs. This is the authoritative list of cron task types (the six event-trigger pattern types are covered above):

task typeBehaviorBinds an employee?Notes
text / agent / reminderStarts an employee conversation on the cron scheduleYes (agent required)Classic scheduled conversation; the result routes to the conversation
wiki_processProcesses a knowledge base offline on the cron scheduleNoNew in 1.4.0 — see below

wiki_process: off-peak KB processing (new in 1.4.0)

wiki_process lets you schedule knowledge-base processing to run offline during low-traffic windows instead of saturating the processing queue the moment an upload finishes. It binds no employee — it's a system task: no conversation, no chat.

When creating one you only fill in:

  • cron expression (use the visual editor above, 5-field)
  • KB selector — which KB this job processes
  • an optional "force reprocess" toggle — when on, already-processed raw materials are rerun too (force)

On each tick, the job asynchronously queues that KB's raw materials for processing and logs one row in Run History, of the form queued N raw material(s) (a (force) suffix is appended when force is on). Note it does not route to any conversation — it just hands work to the processing queue; check progress on the LLM Wiki page.

Payload template

The payload_template field is a Pebble template string; the rendered output becomes the input to the dispatch target (agent conversation or workflow run).

jsonc
"payload_template": "{
  \"date\": \"{{ now | date('yyyy-MM-dd') }}\",
  \"trigger\": \"{{ trigger.name }}\",
  \"sourceEvent\": {{ event | toJson }}
}"

Variables in the template:

  • now — current time
  • trigger.{name,id,workspaceId} — the firing trigger
  • event — the current event envelope (workspaceId / senderId / data JSON, etc.)

Inspecting fire history

mate_trigger_event is dedup metadata only — one row per accepted event with trigger_id / dedup_key / received_at / expires_at, and no copy of the envelope itself. To audit the actual content of a particular event, look at channel-layer logs + the agent / workflow run records.

mate_trigger.fire_count honestly records dispatch count (excluding dedup'd / rate-limited events); mate_trigger.last_error carries the most recent failure reason.


API reference

All endpoints under /api/v1/triggers/. What v1.3.0 actually exposes — the /webhook/{slug} / /test-fire / /{id}/events entries from the RFC are not yet implemented.

MethodPathDescription
GET/api/v1/triggersList all triggers in the current workspace
GET/api/v1/triggers/{id}Get details
POST/api/v1/triggersCreate a new trigger; with enabled=true, registers with scheduler / router immediately
PUT/api/v1/triggers/{id}Update (including enable / disable — flip the enabled field); on pattern_json change, pattern_version++ and stale futures self-cancel cross-instance
DELETE/api/v1/triggers/{id}Soft delete (equivalent to disable)
POST/api/v1/triggers/eventsUnified event entry — any webhook / channel adapter / internal module hands an envelope to the engine; engine runs dedup / bot-self / rate limit / pattern match / dispatch and returns a per-trigger fire / drop summary

Relationship with the existing cron module

Reuse, not replace

Before v1.3.0 MateClaw already had a standalone cron subsystem (mate_cron_job table + CronJobService). The trigger system does not replace it

  • Legacy cron jobs (task_type = text / agent / reminder) remain on the Cron Jobs page
  • New trigger crons live on the Triggers page
  • Both share the underlying ShedLock lock table + Spring TaskScheduler thread pool
  • The mate_cron_job list does not show trigger crons, and vice versa

Why not merge? Because mate_cron_job's legacy schema (required task_type / agentId, etc.) doesn't fit a workflow target. Forcing extra columns would break existing product invariants. CronDelegationPort is the v0 minimal solution — share the scheduler base, split the persistence layer. Folding mate_cron_job into trigger entirely is a future iteration.


Cross-instance consistency (multi-replica deploy)

CronDelegationPort methods are process-local — local ScheduledFuture lives only in this JVM, no persisted handle. Cross-instance consistency relies on:

  1. Every instance, on startup, calls syncFromDatabase() to scan all enabled cron triggers and register locally
  2. When a trigger is updated, pattern_version++ + cancel local future
  3. Each fire re-reads the trigger row before executing; mismatched patternVersionlocal short-circuit + self-cancel (means another instance modified it)
  4. ShedLock key = "mate-trigger-{triggerId}", mutually exclusive across instances
  5. Periodic @Scheduled(fixedDelay=60s) syncFromDatabase() as a fallback reconciler

Practical implication: rolling-deploy multiple replicas needs no extra steps — new instances pick up automatically; old instances finish their last cycle and stop.


Data model

mate_trigger — trigger configuration

Key fields:

FieldTypePurpose
pattern_typevarcharOne of the six patterns
pattern_jsonTEXTThe pattern's filter parameters as JSON
target_typevarcharagent or workflow
target_idbigintForeign key to the agent / workflow
payload_templateTEXTPebble render template
dedup_window_secsintDedup window in seconds
rate_limit_per_minintMax fires per minute
bot_self_filterboolEnable bot-self filter (default true, but the default impl is no-op)
pattern_versionbigintOptimistic-concurrency Lamport counter; auto-bumps on every pattern_json change; cross-instance fires compare before executing and self-cancel on mismatch
fire_countbigintEffective dispatch count (excluding dedup'd / rate-limited drops)
last_errorvarcharMost recent failure reason ("rate-limited" / exception messages)
enabledboolSoft on/off
deletedintSoft delete

mate_trigger_event — dedup metadata

Used only for dedup decisions. Does not store envelope copies:

FieldTypePurpose
idbigintPrimary key
trigger_idbigintTrigger this row dedups against
dedup_keyvarcharUnique index; the engine consults this within dedup_window_secs
received_attimestampInsertion time
expires_attimestampWindow expiry; the same key can re-enter after this point

Design tradeoff

v0 deliberately does not persist envelopes inside mate_trigger_event — full-volume channel events would crush the DB. Event-payload audit relies on channel-layer logs + the run records on the agent / workflow side. If "event replay" becomes a real need, an envelope column gets added later.


Known limitations (v1.3.0)

  • No visualization of trigger → workflow chains — multiple triggers dispatching to the same workflow appear as two independent lists in the UI
  • No inter-trigger priority / dependency — when an event hits multiple triggers, dispatches are serialized by ascending DB id
  • No dedicated webhook entry / IP allowlist — there's no /webhook/{slug} route in v0; /events is the unified entry. Stricter IP control belongs at the front-door nginx / gateway
  • agent_lifecycle granularity is spawned / terminated / crashed — not "started / completed / failed" per step
  • No event replaymate_trigger_event only persists dedup metadata, not envelopes; "redispatch this event" requires the upstream source to re-emit

Troubleshooting

SymptomInvestigate
Cron trigger doesn't fire1) enabled=true? 2) Does the cron expression + timezone parse to a next-fire time? The editor previews it. 3) Is the ShedLock held by another instance? Check the shedlock table.
POST /events returns 200 but no dispatch happensThe response body contains a per-trigger fire / drop summary — look for BOT_SELF / RATE_LIMITED / DEDUPED / PATTERN_MISMATCH
channel_message doesn't fire1) Does the envelope's data.channelType match this trigger's pattern_json.channelType? 2) bot_self_filter=true and a non-default BotSelfFilter is filtering it? 3) For content_match, the substring field must actually appear in data.content
agent_lifecycle doesn't fireConfirm pattern_json.phase is spawned / terminated / crashed (not started / completed / failed)
Cron trigger stops firing after restartLook at startup log for syncFromDatabase() errors; common cause is corrupted pattern_json failing deserialization
mate_trigger.last_error reads "rate-limited"Raise rate_limit_per_min, or split the trigger into multiple ones partitioned by group
bot_self_filter=true doesn't seem to filterConfirm a non-noop BotSelfFilter Spring Bean is registered — the default NoopBotSelfFilter always returns false

  • Workflow — where dispatches go when target_type=workflow
  • Agents — where dispatches go when target_type=agent
  • Channels — the source of channel_message events
  • Security & Approval — webhook secret + ACL backstop