Skip to content

Triggers & schedules ​

Kestrel can start a run without a human clicking anything. Two triggers ship in @kestrel/plugin-triggers: HTTP webhooks for external systems (CI, chatops, monitoring) and cron schedules for recurring work (nightly triage, dependency audits, report generation). Both create a normal session through SessionManager, so approvals, checkpoints, plugins and the console behave exactly as they do for an interactive run.

Configuration ​

json
{
  "plugins": {
    "config": {
      "@kestrel/plugin-triggers": {
        "webhooks": {
          "deploy-review": {
            "prompt": "Review deployment {{payload}} and report risks.",
            "title": "Deploy review",
            "secret": "${DEPLOY_HOOK_SECRET}"
          }
        },
        "schedules": [
          {
            "id": "nightly-deps",
            "cron": "0 2 * * *",
            "prompt": "Audit dependencies and open a summary in the workspace.",
            "title": "Nightly dependency audit"
          }
        ],
        "tickMs": 30000
      }
    }
  }
}

prompt supports three placeholders: (the JSON request body, {} for schedules), (hook or schedule id) and (ISO timestamp). Unknown placeholders are left intact. model and workspace can pin a trigger to a specific model or directory; otherwise the session inherits the server defaults.

Webhooks ​

Each entry registers POST /api/hooks/<id>:

bash
curl -X POST https://kestrel.example.com/api/hooks/deploy-review \
  -H "Authorization: Bearer $KESTREL_TOKEN" \
  -H "X-Kestrel-Hook-Secret: $DEPLOY_HOOK_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"service":"api","version":"1.4.2"}'

The response is 202 with { "hook", "sessionId", "runId" }; poll or stream the session like any other run. A wrong or missing X-Kestrel-Hook-Secret returns 401 and starts nothing. secret expands ${NAME} from the environment, so the config can stay in version control. When secret is omitted the hook is still protected by normal server authentication, but anyone holding a server token can call it.

Set "public": true only when the caller cannot carry the server token (for example a third-party webhook sender). Public routes are dispatched before authentication, so Kestrel requires a secret for them and rejects the configuration otherwise. Keep public hooks narrow: the secret is the only gate.

Schedules ​

Schedules use five-field cron in UTC: minute, hour, day-of-month, month, day-of-week. The dialect supports *, lists (1,15), ranges (9-17), steps (*/15, 0-30/10) and both 0 and 7 for Sunday. Standard cron OR semantics apply when both day fields are restricted.

The scheduler evaluates once per tickMs (default 30 s, minimum 1 s) and fires at most once per minute. A run that is still active when the next tick matches is skipped with a warning rather than started twice. There is no catch-up: a server that was down at 02:00 does not run the 02:00 job on boot. Schedules live in configuration, not in the database, so restarting with the same config restores them.

Plugin HTTP routes ​

The triggers plugin uses a service any plugin can consume:

ts
export default {
  name: "@acme/status",
  version: "0.1.0",
  inject: ["http.routes"],
  apply(context: Context) {
    const routes = context.get("http.routes");
    context.effect(() =>
      routes.route({
        method: "GET",
        path: "/api/status/:team",
        handler: (request) => ({
          body: { team: request.params.team, query: request.query },
        }),
      }),
    );
  },
};

Routes must live under /api/. :name segments become request.params; the query string arrives as request.query, the decoded JSON body as request.body, and the authenticated caller as request.principal. Return { status?, headers?, body? }; the default is 200 { ok: true }. Handlers are isolated: a thrown error becomes 500 plugin_route_failed and does not stop the server.

Private routes run after authentication and tenant resolution. Public routes (public: true) run before it, so they must verify their own credential. Registration is first-come and duplicate method/path pairs throw, preventing one plugin from shadowing another. Disposers returned by route() run when the plugin unloads.

Limits ​

  • In-process scheduler: one server replica owns the schedule. Running multiple replicas would fire each job once per replica.
  • No retry or dead-letter queue. A failed run is a session with status error; inspect it in the console or resume it.
  • No delivery history yet: hook calls are logged, not stored.

Released under the MIT License.