# Plan: Implement Webhook, Cron, and Chain Trigger Types

## Overview

Add three new trigger types to Didactyl's trigger system alongside the existing `nostr-subscription` type. Implementation order: webhook → cron → chain, followed by documentation updates.

## Current Architecture

```mermaid
flowchart LR
    subgraph Current Flow
        NS[Nostr Event] --> SUB[Subscription Callback]
        SUB --> MF[maybe_fire_trigger_locked]
        MF --> EA{Action Type?}
        EA -->|template| TA[execute_template_action]
        EA -->|llm| LA[execute_llm_action]
        LA --> AOT[agent_on_trigger]
    end
```

All triggers are implicitly `nostr-subscription` — there is no type discriminator field in `active_trigger_t`.

## Target Architecture

```mermaid
flowchart TD
    subgraph Trigger Sources
        NS[Nostr Subscription]
        WH[Webhook HTTP POST]
        CR[Cron Timer]
        CH[Chain - Post-Execution]
    end

    subgraph Trigger Manager
        NS --> MF[maybe_fire_trigger]
        WH --> MF
        CR --> MF
        CH --> MF
    end

    subgraph Dispatch
        MF --> EA{Action Type?}
        EA -->|template| TA[execute_template_action]
        EA -->|llm| LA[execute_llm_action]
        LA --> AOT[agent_on_trigger]
        AOT -->|on completion| CHK[Check chain triggers]
        CHK -->|match| CH
    end
```

---

## Phase 1: Foundation — Type System and API Changes

### 1.1 Add trigger type enum to `trigger_manager.h`

**File:** [`src/trigger_manager.h`](src/trigger_manager.h:15)

Add a new enum after the existing `trigger_action_type_t` at line 18:

```c
typedef enum {
    TRIGGER_TYPE_NOSTR_SUBSCRIPTION = 0,
    TRIGGER_TYPE_WEBHOOK,
    TRIGGER_TYPE_CRON,
    TRIGGER_TYPE_CHAIN
} trigger_type_t;
```

### 1.2 Extend `active_trigger_t` struct

**File:** [`src/trigger_manager.h`](src/trigger_manager.h:20)

Add three new fields to the struct after [`enabled`](src/trigger_manager.h:25):

```c
trigger_type_t trigger_type;     // discriminator: which trigger source
time_t last_cron_fire;           // cron: last time this trigger fired
char cron_expr[64];              // cron: parsed 5-field cron expression
```

The struct currently has `subscription` and `subscription_ctx` fields which are only relevant for `nostr-subscription` — they'll remain but be NULL for other types.

### 1.3 Add trigger type string conversion helpers

**File:** [`src/trigger_manager.h`](src/trigger_manager.h:57) — add declarations:

```c
trigger_type_t trigger_type_from_string(const char *s);
const char* trigger_type_to_string(trigger_type_t t);
```

**File:** [`src/trigger_manager.c`](src/trigger_manager.c:20) — add implementations after `clamp_enabled()`:

```c
trigger_type_t trigger_type_from_string(const char *s) {
    if (!s) return TRIGGER_TYPE_NOSTR_SUBSCRIPTION;
    if (strcmp(s, "webhook") == 0) return TRIGGER_TYPE_WEBHOOK;
    if (strcmp(s, "cron") == 0) return TRIGGER_TYPE_CRON;
    if (strcmp(s, "chain") == 0) return TRIGGER_TYPE_CHAIN;
    return TRIGGER_TYPE_NOSTR_SUBSCRIPTION;
}

const char* trigger_type_to_string(trigger_type_t t) {
    switch (t) {
        case TRIGGER_TYPE_WEBHOOK: return "webhook";
        case TRIGGER_TYPE_CRON: return "cron";
        case TRIGGER_TYPE_CHAIN: return "chain";
        default: return "nostr-subscription";
    }
}
```

### 1.4 Update `trigger_manager_add()` signature

**File:** [`src/trigger_manager.h`](src/trigger_manager.h:44) and [`src/trigger_manager.c`](src/trigger_manager.c:569)

Add `const char* trigger_type_str` parameter:

```c
int trigger_manager_add(trigger_manager_t* mgr,
                        const char* skill_d_tag,
                        const char* content,
                        const char* filter_json,
                        trigger_action_type_t action_type,
                        const char* trigger_type_str,   // NEW
                        int enabled);
```

Inside the function body at [line 604](src/trigger_manager.c:604), after `memset(t, 0, sizeof(*t))` and field assignments:

```c
t->trigger_type = trigger_type_from_string(trigger_type_str);

// For cron triggers, copy the filter as the cron expression
if (t->trigger_type == TRIGGER_TYPE_CRON) {
    snprintf(t->cron_expr, sizeof(t->cron_expr), "%s", filter_json);
    t->last_cron_fire = 0;
}

// Only create Nostr subscription for nostr-subscription type
if (t->trigger_type == TRIGGER_TYPE_NOSTR_SUBSCRIPTION) {
    if (register_trigger_subscription_locked(mgr, t) != 0) {
        pthread_mutex_unlock(&mgr->mutex);
        DEBUG_WARN("[didactyl] trigger add rejected: subscription failed d_tag=%s", skill_d_tag);
        memset(t, 0, sizeof(*t));
        return -1;
    }
}
```

### 1.5 Update `trigger_manager_update()` signature

**File:** [`src/trigger_manager.h`](src/trigger_manager.h:51) and [`src/trigger_manager.c`](src/trigger_manager.c:661)

Same pattern — add `const char* trigger_type_str` parameter. Inside the body at [line 683](src/trigger_manager.c:683):

```c
t->trigger_type = trigger_type_from_string(trigger_type_str);

if (t->trigger_type == TRIGGER_TYPE_CRON) {
    snprintf(t->cron_expr, sizeof(t->cron_expr), "%s", filter_json);
}

// Only (re)subscribe for nostr-subscription type
if (t->trigger_type == TRIGGER_TYPE_NOSTR_SUBSCRIPTION) {
    if (register_trigger_subscription_locked(mgr, t) != 0) { ... }
} else {
    // Close any existing subscription if type changed
    close_trigger_subscription_locked(t);
}
```

### 1.6 Update all call sites of `trigger_manager_add()` and `trigger_manager_update()`

There are 5 call sites that need the new `trigger_type_str` parameter:

| Call Site | File | Line | Current `trigger_type_str` value |
|---|---|---|---|
| `trigger_manager_load_from_skills()` | [`src/trigger_manager.c`](src/trigger_manager.c:485) | 485 | `trigger_s` — already extracted from tags |
| `trigger_manager_load_from_startup_events()` | [`src/trigger_manager.c`](src/trigger_manager.c:555) | 555 | `trigger_s` — already extracted from tags |
| `execute_skill_create()` | [`src/tools.c`](src/tools.c:3678) | 3678 | `trigger_str` — already available |
| `execute_skill_edit()` update call | [`src/tools.c`](src/tools.c:4213) | 4213 | `merged_trigger` — already available |
| `execute_skill_edit()` add fallback | [`src/tools.c`](src/tools.c:4219) | 4219 | `merged_trigger` — already available |
| `trigger_manager_add()` → `trigger_manager_update()` cross-call | [`src/trigger_manager.c`](src/trigger_manager.c:588) | 588 | Pass through from caller |
| `trigger_manager_update()` → `trigger_manager_add()` cross-call | [`src/trigger_manager.c`](src/trigger_manager.c:680) | 680 | Pass through from caller |

### 1.7 Update loading functions to accept all trigger types

**File:** [`src/trigger_manager.c`](src/trigger_manager.c:482)

In `trigger_manager_load_from_skills()`, change the filter at line 482 from:
```c
if (trigger_s && strcmp(trigger_s, "nostr-subscription") == 0 && filter_s && filter_s[0] != '\0') {
```
To:
```c
if (trigger_s && filter_s && filter_s[0] != '\0' &&
    (strcmp(trigger_s, "nostr-subscription") == 0 ||
     strcmp(trigger_s, "webhook") == 0 ||
     strcmp(trigger_s, "cron") == 0 ||
     strcmp(trigger_s, "chain") == 0)) {
```

**File:** [`src/trigger_manager.c`](src/trigger_manager.c:547)

Same change in `trigger_manager_load_from_startup_events()` at line 547.

Note: For `webhook` type, `filter` can be empty/unused since webhooks are triggered by HTTP POST, not by a filter match. Consider allowing empty filter for webhook type. However, keeping the existing requirement that filter must be non-empty is simpler — webhook skills can use `filter: "{}"` as a placeholder.

### 1.8 Update `trigger_manager_status_json()`

**File:** [`src/trigger_manager.c`](src/trigger_manager.c:761)

After the existing `cJSON_AddStringToObject(item, "filter_json", ...)` at line 762, add:

```c
cJSON_AddStringToObject(item, "type", trigger_type_to_string(t->trigger_type));
```

### 1.9 Update `tools.c` validation

**File:** [`src/tools.c`](src/tools.c:3603)

In `execute_skill_create()`, change line 3603 from:
```c
if (trigger_str && strcmp(trigger_str, "nostr-subscription") != 0) {
```
To:
```c
if (trigger_str &&
    strcmp(trigger_str, "nostr-subscription") != 0 &&
    strcmp(trigger_str, "webhook") != 0 &&
    strcmp(trigger_str, "cron") != 0 &&
    strcmp(trigger_str, "chain") != 0) {
```

Update the error message to list valid types.

**File:** [`src/tools.c`](src/tools.c:4177)

In `execute_skill_edit()`, change line 4177 from:
```c
if (strcmp(merged_trigger, "nostr-subscription") != 0) {
```
To the same multi-type check.

Also update the `trigger_manager_add()` and `trigger_manager_update()` calls at [lines 3678](src/tools.c:3678) and [4213-4224](src/tools.c:4213) to pass the trigger type string.

---

## Phase 2: Webhook Trigger

### 2.1 Add `POST /api/trigger/:d_tag` route handler

**File:** [`src/http_api.c`](src/http_api.c:1154)

Add a new handler function before `http_handler()`:

```c
static void handle_trigger_webhook(struct mg_connection* c, struct mg_http_message* hm, const char* d_tag) {
    if (!g_api_ctx.trigger_manager) {
        reply_error(c, 503, "trigger manager unavailable");
        return;
    }

    // Look up the trigger by d_tag
    // Need a new function: trigger_manager_find_by_d_tag() that returns a copy
    active_trigger_t trigger_copy;
    if (trigger_manager_find(g_api_ctx.trigger_manager, d_tag, &trigger_copy) != 0) {
        reply_error(c, 404, "no trigger found for d_tag");
        return;
    }

    if (trigger_copy.trigger_type != TRIGGER_TYPE_WEBHOOK) {
        reply_error(c, 400, "trigger is not a webhook type");
        return;
    }

    if (!trigger_copy.enabled) {
        reply_error(c, 400, "trigger is disabled");
        return;
    }

    // Parse optional JSON body as the webhook payload
    cJSON* payload = parse_body_json(hm);
    if (!payload) {
        reply_error(c, 400, "invalid JSON body");
        return;
    }

    // Build synthetic triggering event
    cJSON* event = cJSON_CreateObject();
    cJSON_AddStringToObject(event, "type", "webhook");
    cJSON_AddStringToObject(event, "d_tag", d_tag);
    cJSON_AddNumberToObject(event, "created_at", (double)time(NULL));
    cJSON_AddItemToObject(event, "payload", payload);

    // Fire the trigger - dispatch based on action type
    if (trigger_copy.action_type == TRIGGER_ACTION_LLM) {
        agent_on_trigger(trigger_copy.skill_d_tag,
                        trigger_copy.skill_content,
                        event,
                        "webhook");
    }
    // Template actions could also be supported here

    cJSON_Delete(event);

    // Return success immediately
    cJSON* root = cJSON_CreateObject();
    cJSON_AddBoolToObject(root, "success", 1);
    cJSON_AddStringToObject(root, "d_tag", d_tag);
    cJSON_AddStringToObject(root, "status", "fired");
    reply_json(c, 200, root);
    cJSON_Delete(root);
}
```

### 2.2 Add route to `http_handler()`

**File:** [`src/http_api.c`](src/http_api.c:1201)

Before the 404 fallthrough at line 1206, add:

```c
if (method_is(hm, "POST") && mg_match(hm->uri, mg_str("/api/trigger/*"), NULL)) {
    // Extract d_tag from URI: /api/trigger/{d_tag}
    struct mg_str uri = hm->uri;
    const char* prefix = "/api/trigger/";
    size_t prefix_len = strlen(prefix);
    if (uri.len > prefix_len) {
        char d_tag[TRIGGER_SKILL_D_TAG_MAX];
        size_t tag_len = uri.len - prefix_len;
        if (tag_len >= sizeof(d_tag)) tag_len = sizeof(d_tag) - 1;
        memcpy(d_tag, uri.buf + prefix_len, tag_len);
        d_tag[tag_len] = '\0';
        handle_trigger_webhook(c, hm, d_tag);
        return;
    }
    reply_error(c, 400, "missing d_tag in trigger URL");
    return;
}
```

### 2.3 Add `trigger_manager_find()` function

**File:** [`src/trigger_manager.h`](src/trigger_manager.h:50) and [`src/trigger_manager.c`](src/trigger_manager.c:631)

New function to look up a trigger by d_tag and return a copy:

```c
// Declaration
int trigger_manager_find(trigger_manager_t* mgr, const char* skill_d_tag, active_trigger_t* out);

// Implementation
int trigger_manager_find(trigger_manager_t* mgr, const char* skill_d_tag, active_trigger_t* out) {
    if (!mgr || !skill_d_tag || !out) return -1;
    pthread_mutex_lock(&mgr->mutex);
    int idx = find_trigger_index_locked(mgr, skill_d_tag);
    if (idx < 0) {
        pthread_mutex_unlock(&mgr->mutex);
        return -1;
    }
    *out = mgr->triggers[idx];
    // Clear pointer fields in copy to prevent double-free
    out->subscription = NULL;
    out->subscription_ctx = NULL;
    pthread_mutex_unlock(&mgr->mutex);
    return 0;
}
```

### 2.4 Webhook cooldown

The webhook handler should respect the same cooldown as other triggers. Either:
- Call `maybe_fire_trigger_locked()` from the webhook handler (requires refactoring to expose it), or
- Add cooldown checking in the webhook handler using `trigger_copy.last_fired` and updating it via a new `trigger_manager_mark_fired()` function

Recommended: Add a `trigger_manager_fire()` function that encapsulates the cooldown check and dispatch, usable by both the Nostr subscription callback and the webhook handler. This avoids duplicating cooldown logic.

```c
int trigger_manager_fire(trigger_manager_t* mgr, const char* skill_d_tag, cJSON* event, const char* source);
```

---

## Phase 3: Cron Trigger

### 3.1 Cron expression parser

**File:** [`src/trigger_manager.c`](src/trigger_manager.c)

Add a minimal 5-field cron expression matcher. The cron expression format is: `minute hour day-of-month month day-of-week`

```c
// Returns 1 if the cron expression matches the given time, 0 otherwise
static int cron_matches(const char* expr, const struct tm* tm);

// Helper: check if a single field matches a value
// Supports: *, specific number, comma-separated list, ranges with -, step with /
static int cron_field_matches(const char* field, int value, int min, int max);
```

The parser needs to handle:
- `*` — match any
- `5` — match exact value
- `1,15` — match list
- `1-5` — match range
- `*/15` — match step
- `1-5/2` — match range with step

This is ~80-100 lines of C. Keep it simple — no named days/months, no special strings like `@hourly`.

### 3.2 Implement `trigger_manager_poll()` for cron

**File:** [`src/trigger_manager.c`](src/trigger_manager.c:718)

Replace the no-op `trigger_manager_poll()` with cron checking logic:

```c
int trigger_manager_poll(trigger_manager_t* mgr) {
    if (!mgr) return 0;

    time_t now = time(NULL);

    // Only check once per minute (cron resolution is 1 minute)
    if (now - mgr->last_poll_at < 60) return 0;
    mgr->last_poll_at = now;

    struct tm tm_now;
    localtime_r(&now, &tm_now);

    pthread_mutex_lock(&mgr->mutex);

    for (int i = 0; i < mgr->count; i++) {
        active_trigger_t* t = &mgr->triggers[i];
        if (!t->enabled || t->trigger_type != TRIGGER_TYPE_CRON) continue;
        if (t->cron_expr[0] == '\0') continue;

        if (!cron_matches(t->cron_expr, &tm_now)) continue;

        // Prevent double-fire within same minute
        struct tm tm_last;
        localtime_r(&t->last_cron_fire, &tm_last);
        if (t->last_cron_fire > 0 &&
            tm_last.tm_min == tm_now.tm_min &&
            tm_last.tm_hour == tm_now.tm_hour &&
            tm_last.tm_mday == tm_now.tm_mday) {
            continue;
        }

        t->last_cron_fire = now;
        t->last_fired = now;

        // Copy trigger data before unlocking
        active_trigger_t trigger_copy = *t;
        trigger_copy.subscription = NULL;
        trigger_copy.subscription_ctx = NULL;
        pthread_mutex_unlock(&mgr->mutex);

        // Build synthetic cron event
        cJSON* event = cJSON_CreateObject();
        cJSON_AddStringToObject(event, "type", "cron");
        cJSON_AddStringToObject(event, "d_tag", trigger_copy.skill_d_tag);
        cJSON_AddNumberToObject(event, "created_at", (double)now);
        cJSON_AddStringToObject(event, "cron_expr", trigger_copy.cron_expr);

        if (trigger_copy.action_type == TRIGGER_ACTION_TEMPLATE) {
            execute_template_action(mgr, &trigger_copy, event, "cron");
        } else {
            execute_llm_action(&trigger_copy, event, "cron");
        }

        cJSON_Delete(event);

        // Re-lock and continue scanning
        pthread_mutex_lock(&mgr->mutex);
    }

    pthread_mutex_unlock(&mgr->mutex);
    return 0;
}
```

### 3.3 Cron expression in skill tags

For cron triggers, the `filter` tag contains the cron expression instead of a JSON filter:
- `["trigger", "cron"]`
- `["filter", "0 * * * *"]` — fires every hour at minute 0
- `["filter", "*/5 * * * *"]` — fires every 5 minutes

This reuses the existing `filter` tag semantics — the filter meaning depends on the trigger type.

---

## Phase 4: Chain Trigger

### 4.1 Post-execution hook in `agent_on_trigger()`

**File:** [`src/agent.c`](src/agent.c:1913)

After the tool loop completes at [line 2041](src/agent.c:2041), before cleanup, add a chain trigger check:

```c
// After the tool loop, check for chain triggers
// Need access to trigger_manager — use agent_get_trigger_manager() or global
trigger_manager_fire_chains(g_trigger_manager, skill_d_tag, messages);
```

This requires:
1. The agent needs access to the trigger manager — it already has this via [`agent_set_trigger_manager()`](src/agent.h:12)
2. A new function `trigger_manager_fire_chains()` that scans for chain triggers whose filter matches the completed skill's d_tag

### 4.2 Add `trigger_manager_fire_chains()` function

**File:** [`src/trigger_manager.c`](src/trigger_manager.c)

```c
void trigger_manager_fire_chains(trigger_manager_t* mgr,
                                  const char* source_d_tag,
                                  cJSON* source_output) {
    if (!mgr || !source_d_tag) return;

    pthread_mutex_lock(&mgr->mutex);

    for (int i = 0; i < mgr->count; i++) {
        active_trigger_t* t = &mgr->triggers[i];
        if (!t->enabled || t->trigger_type != TRIGGER_TYPE_CHAIN) continue;

        // For chain triggers, filter_json contains the source skill d_tag
        if (strcmp(t->filter_json, source_d_tag) != 0) continue;

        // Cooldown check
        time_t now = time(NULL);
        int cooldown = mgr->cfg->triggers.cooldown_seconds;
        if (cooldown > 0 && t->last_fired > 0 && (now - t->last_fired) < cooldown) continue;

        t->last_fired = now;

        active_trigger_t trigger_copy = *t;
        trigger_copy.subscription = NULL;
        trigger_copy.subscription_ctx = NULL;
        pthread_mutex_unlock(&mgr->mutex);

        // Build synthetic chain event
        cJSON* event = cJSON_CreateObject();
        cJSON_AddStringToObject(event, "type", "chain");
        cJSON_AddStringToObject(event, "source_d_tag", source_d_tag);
        cJSON_AddNumberToObject(event, "created_at", (double)now);
        if (source_output) {
            cJSON* output_copy = cJSON_Duplicate(source_output, 1);
            if (output_copy) {
                cJSON_AddItemToObject(event, "source_output", output_copy);
            }
        }

        if (trigger_copy.action_type == TRIGGER_ACTION_LLM) {
            execute_llm_action(&trigger_copy, event, "chain");
        }
        // Note: template actions could also be supported

        cJSON_Delete(event);

        pthread_mutex_lock(&mgr->mutex);
    }

    pthread_mutex_unlock(&mgr->mutex);
}
```

### 4.3 Chain trigger recursion protection

To prevent infinite chain loops (A → B → A → B → ...), add a chain depth counter:

- Add a `static __thread int chain_depth = 0;` in `trigger_manager_fire_chains()`
- Increment before firing, decrement after
- Refuse to fire if depth exceeds a limit (e.g., 5)

### 4.4 Chain trigger skill tags

For chain triggers, the `filter` tag contains the source skill's d_tag:
- `["trigger", "chain"]`
- `["filter", "data-fetcher"]` — fires after `data-fetcher` skill completes

### 4.5 What to pass as chain context

The chain trigger's synthetic event should include the source skill's final LLM response. This requires capturing the last assistant message from the tool loop in `agent_on_trigger()`. Currently the function doesn't return any output — it just runs and exits.

**Approach:** After the tool loop at [line 2041](src/agent.c:2041), extract the last assistant message from `messages` array and pass it to `trigger_manager_fire_chains()`.

---

## Phase 5: Documentation

### 5.1 Update `docs/SKILLS.md`

**File:** [`docs/SKILLS.md`](docs/SKILLS.md:209)

Expand the Trigger Tags table to show all four types and their filter semantics:

| Tag | Required | Description |
|---|---|---|
| `trigger` | Yes | Trigger type: `nostr-subscription`, `webhook`, `cron`, or `chain` |
| `filter` | Yes | Type-dependent: JSON filter, empty/unused, cron expression, or source d_tag |
| `action` | No | `template` or `llm` — default: `llm` |
| `enabled` | No | Whether active — default: `true` |

Add sections for each new trigger type with examples.

Update the Future Extensions table to mark webhook, cron, and chain as implemented.

### 5.2 Update `docs/API.md`

**File:** [`docs/API.md`](docs/API.md:49)

Add documentation for the new webhook endpoint:

```
### POST /api/trigger/:d_tag

Fire a webhook trigger by skill d_tag.

**URL Parameters:**
- `d_tag` — The skill's d_tag identifier

**Request Body:** Optional JSON payload passed as context to the skill

**Response:**
{
  "success": true,
  "d_tag": "my-webhook-skill",
  "status": "fired"
}
```

---

## Phase 6: Build and Push

1. Run `make -j` and fix any compilation warnings/errors
2. Test webhook with: `curl -X POST http://localhost:8484/api/trigger/test-skill -d '{"message":"hello"}'`
3. Push with `./increment_and_push.sh "feat: add webhook, cron, and chain trigger types"`

---

## Files Modified Summary

| File | Changes |
|---|---|
| [`src/trigger_manager.h`](src/trigger_manager.h) | Add `trigger_type_t` enum, extend `active_trigger_t`, update function signatures, add new declarations |
| [`src/trigger_manager.c`](src/trigger_manager.c) | Type conversion helpers, update load/add/update functions, cron parser, poll implementation, chain fire function, find function |
| [`src/tools.c`](src/tools.c) | Update `execute_skill_create()` and `execute_skill_edit()` validation and call sites |
| [`src/http_api.c`](src/http_api.c) | Add webhook route handler and route entry |
| [`src/agent.c`](src/agent.c) | Add chain trigger post-execution hook in `agent_on_trigger()` |
| [`src/agent.h`](src/agent.h) | No changes needed — `agent_on_trigger()` signature unchanged |
| [`docs/SKILLS.md`](docs/SKILLS.md) | Document all three new trigger types |
| [`docs/API.md`](docs/API.md) | Document webhook endpoint |

---

## Implementation Checklist

- [ ] Add `trigger_type_t` enum to `trigger_manager.h`
- [ ] Extend `active_trigger_t` struct with `trigger_type`, `last_cron_fire`, `cron_expr`
- [ ] Add `trigger_type_from_string()` and `trigger_type_to_string()` declarations and implementations
- [ ] Add `trigger_manager_find()` declaration and implementation
- [ ] Add `trigger_manager_fire_chains()` declaration and implementation
- [ ] Update `trigger_manager_add()` signature with `trigger_type_str` parameter
- [ ] Update `trigger_manager_update()` signature with `trigger_type_str` parameter
- [ ] Update `trigger_manager_add()` body: set trigger_type, conditional subscription registration
- [ ] Update `trigger_manager_update()` body: set trigger_type, conditional subscription
- [ ] Update `trigger_manager_load_from_skills()` to accept all 4 trigger types
- [ ] Update `trigger_manager_load_from_startup_events()` to accept all 4 trigger types
- [ ] Update `trigger_manager_status_json()` to include type field
- [ ] Update `execute_skill_create()` validation at line 3603
- [ ] Update `execute_skill_create()` `trigger_manager_add()` call at line 3678
- [ ] Update `execute_skill_edit()` validation at line 4177
- [ ] Update `execute_skill_edit()` `trigger_manager_update()` and `trigger_manager_add()` calls at lines 4213-4224
- [ ] Add `handle_trigger_webhook()` handler to `http_api.c`
- [ ] Add webhook route to `http_handler()` in `http_api.c`
- [ ] Implement `cron_field_matches()` and `cron_matches()` in `trigger_manager.c`
- [ ] Implement cron polling in `trigger_manager_poll()`
- [ ] Add chain trigger post-execution hook in `agent_on_trigger()`
- [ ] Add chain depth recursion protection
- [ ] Update `docs/SKILLS.md` with all three new trigger types
- [ ] Update `docs/API.md` with webhook endpoint
- [ ] Build with `make -j` and verify clean compilation
- [ ] Push with `./increment_and_push.sh`
