kortecxdocs

Scheduling and Triggers

Make a run start itself — on a clock, when a webhook lands, or on an authenticated gRPC call — with signed inbound requests, replay dedup, and a dry run that rehearses the wiring without firing it.

Most of the time you start a run yourself. A trigger removes you from that step. It binds an inbound event to something you have already built, so when the event arrives the agentic runtime starts a fresh run on its own.

A trigger is the run's origin, not a different kind of run. The event goes through the same path a manual start does, so the journal, replay and approvals all behave identically. Whether a schedule fired it or you did, you get the same durable run you can inspect later.

The three kinds

KindWhat starts the runWho has to authenticate
crona clock — an interval, or a time of daynobody; the scheduler is internal
webhookan HTTP POST from outsidethe caller — this is untrusted inbound
grpcan authenticated call to the gatewaythe caller, on the gateway's existing bearer gate

Every trigger points at exactly one target: a recipe handle (--recipe, a named piece of runtime work) or a saved app (--app). An app target runs unattended with its registered connections and secret scope resolved, the same way it runs when you start it by hand. See Apps for what a saved app is, and Scheduled apps for the app-shaped version of this page.

Check that your target exists on this serve

The examples below use kx/recipes/chat, a built-in single-step recipe with one parameter, prompt. It is provisioned only when the serve resolved a model to run — on a serve with no served model the handle is not there and a trigger pointed at it fails to bind. An app target additionally needs the app-run path, which is a non-default build (the mcp-gateway cargo feature) with a connections database; without it kx triggers add --app … is refused at registration. Run kx triggers test before you enable anything.

A new trigger is off until you say otherwise

kx triggers add registers the trigger disabled unless you pass --enabled. That is deliberate — you can register the wiring, rehearse it with kx triggers test, and only then turn it on. A registered trigger with no --enabled will never fire and will not tell you so. (The Python and TypeScript SDKs are the other way round: enabled defaults to true, so pass it explicitly if you want the CLI's behaviour.)

A trigger on a clock

The clock is the simplest kind. There is no inbound network surface at all, so there is nothing to authenticate.

--schedule (spelled --cron if you prefer) takes one of two shapes, and the runtime tells them apart for you:

  • an interval in seconds3600 means "every hour". It must be greater than zero.
  • a 5-field crontab expression"0 9 * * 1-5" means minute 0, hour 9, any day of the month, any month, Monday through Friday. Because a crontab expression always contains spaces and an interval never does, the two are unambiguous.

A crontab expression is read in the timezone you give with --timezone, which takes any IANA zone name such as America/New_York. Leave it out and the expression is read in UTC. Daylight-saving shifts are handled, so "09:00 New York" stays 09:00 in New York across the change.

A scheduled tick carries no payload

When the clock fires a trigger, the runtime submits an empty payload ({}). There is no stored argument template. A saved app carries its own instructions, so it runs fine on an empty payload — that is why the examples here schedule an app. A recipe that requires a parameter (kx/recipes/chat requires prompt) has nothing to supply it on a tick and will fail to bind on every fire, so point a clock at an app, or at a recipe that needs no arguments.

# every hour, on an interval
kx triggers add --name hourly-digest --kind cron --app standup-digest \
  --schedule 3600 --enabled

# 09:00 on weekdays, New York time
kx triggers add --name standup --kind cron --app standup-digest \
  --cron "0 9 * * 1-5" --timezone America/New_York \
  --require-approval --enabled
from kortecx import KxClient

with KxClient() as kx:
    # every hour (the interval is in seconds)
    kx.triggers.add("hourly-digest", kind="cron", app="standup-digest",
                    schedule="3600", enabled=True)

    # 09:00 on weekdays, New York time
    kx.triggers.add("standup", kind="cron", app="standup-digest",
                    schedule="0 9 * * 1-5", timezone="America/New_York",
                    require_approval=True, enabled=True)
import { KxClient } from "@kortecx/sdk";

const kx = new KxClient("http://127.0.0.1:50151");

await kx.triggers.add({ name: "hourly-digest", kind: "cron",
  appHandle: "standup-digest", scheduleSpec: "3600", enabled: true });

await kx.triggers.add({ name: "standup", kind: "cron",
  appHandle: "standup-digest", scheduleSpec: "0 9 * * 1-5",
  timezone: "America/New_York", requireApproval: true, enabled: true });

A bad expression or an unknown timezone is rejected at the moment you register it, with an error naming the problem. You will not find out three days later that a typo meant nothing ever ran.

The scheduler scans for due triggers every 5 seconds, so a trigger fires within a few seconds of its scheduled time rather than on the exact millisecond. If a fire fails, that tick is missed and the next scheduled time is tried — the local scheduler makes no exactly-once delivery promise.

Rehearsing the wiring without firing it

Before you enable anything, check that the trigger actually resolves. kx triggers test does everything a real event does except start the run: it looks up the target, resolves the connections and secrets an app would need, and binds your payload to it. Nothing is journaled and nothing runs.

kx triggers test --name standup --payload '{"prompt":"draft the standup digest"}'

You get back whether it resolved and a short reason if it did not — an unbound payload, a missing integration, a model route this serve does not have. Fix those first, then enable.

Rehearse with the payload the real event will send

test binds the payload you pass it. A clock tick sends {}, so rehearse a cron trigger with no --payload to see what it will really do.

kx triggers fire is the opposite: it starts a real, journaled run right now, using the same path a real inbound event uses. Use it to confirm the end-to-end behaviour once test is clean. It works on a trigger of any kind, not only a grpc one.

kx triggers fire --name standup --payload '{"prompt":"draft the standup digest"}'

test rehearses, fire commits

test answers "would this route?". fire answers "what happens when it does?". Only fire produces a run you can replay.

Webhooks: the untrusted inbound surface

A webhook accepts an HTTP POST from wherever you expose it. It is the one trigger kind where a stranger can reach the runtime, so it is treated as untrusted and every control fails closed.

The listener is off by default

No amount of trigger registration opens a port. The webhook listener exists only when you ask for it by address:

kx serve --dev-allow-local --webhook-listen 127.0.0.1:50190

Binding to a non-loopback address is allowed, but the runtime warns at startup — and the "no authentication" posture stops being accepted there.

What every webhook enforces

  • Authentication, pinned per trigger. hmac_sha256 requires the caller to sign the raw request body with a shared secret and send the hex digest in X-Kx-Signature-256. bearer requires a token in the Authorization header. Both are compared in constant time. none is accepted only when the listener is bound to loopback; off loopback a none webhook is refused.
  • A payload cap. A body over 256 KiB is refused with 413, so an oversized body cannot be used to exhaust the process.
  • A rate limit. Each trigger gets its own token bucket — 20 requests of burst, refilling at 10 per second — and requests beyond it get 429. This applies even to a correctly authenticated caller.
  • No existence oracle. An unknown trigger, a disabled trigger, a bad signature and a missing header all return the same 401. A stranger cannot probe your trigger names.

An HMAC or bearer trigger is rejected at registration if you have not given it a secret to verify against.

Setting one up

Store the secret. Never put the value in the trigger. Put it in the secret store and refer to it by name.

kx secrets set --name HOOK_SECRET --value <hex-shared-secret>

Start the runtime with the listener open.

kx serve --dev-allow-local --webhook-listen 127.0.0.1:50190

Register the trigger, naming the secret rather than carrying it. The payload the sender POSTs becomes the target's arguments, so the target here is kx/recipes/chat and the payload carries its one parameter, prompt.

kx triggers add --name alert --kind webhook \
  --recipe kx/recipes/chat --auth hmac_sha256 \
  --secret-ref HOOK_SECRET --enabled

Rehearse it, then deliver a signed event. The signature is HMAC-SHA256 over the exact bytes you send, so sign the raw body before any reformatting.

kx triggers test --name alert --payload '{"prompt":"diagnose the alert"}'

SECRET="<hex-shared-secret>"          # the value behind HOOK_SECRET
BODY='{"prompt":"diagnose the alert"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')

curl -sS http://127.0.0.1:50190/trigger/alert \
  -H "Content-Type: application/json" \
  -H "X-Kx-Signature-256: sha256=$SIG" \
  -H "X-Kx-Idempotency-Key: alert-2026-06-27-001" \
  --data-raw "$BODY"

The POST goes to /trigger/<name> and the reply tells you which run it started:

{ "instance_id": "a1b2c3…", "deduped": false }

The idempotency key

Systems that deliver webhooks retry. If the network drops your response, the sender assumes failure and POSTs the same event again. Without protection, one real-world event becomes two runs — and if the run sends a message or writes to a system of record, that is a duplicate that matters.

Every event carries an idempotency key: a short string that identifies the event, not the request. The runtime records which run a key produced. A second event with the same key starts nothing and returns the first run's instance_id with "deduped": true.

You get this three ways:

  • Explicitly, with the X-Kx-Idempotency-Key header on a webhook, or --idempotency-key on kx triggers fire. Use the sender's own event id when it has one.
  • Derived, when you supply no key: the runtime derives one from the trigger and the exact payload, so a byte-identical replay collapses onto the original run.
  • From the schedule, for a cron trigger: the key is bound to the scheduled tick that made it due, so a retried or raced tick never fires twice.

A key is only recorded once a run actually starts, so the second call below dedups only if the first one bound and started a run.

kx triggers fire --name alert --idempotency-key evt-1 --payload '{"prompt":"diagnose the alert"}'
kx triggers fire --name alert --idempotency-key evt-1 --payload '{"prompt":"diagnose the alert"}'
# the second returns the first run's instance_id with "deduped": true

This is what makes at-least-once delivery safe to point at something that has real effects.

One caveat, honestly

Two identical events arriving at the same instant can race, and the loser is an inert extra rather than a merge. It is a narrow local window; stronger cross-event dedup is a Cloud capability.

Triggers fired over gRPC

A grpc trigger has no HTTP surface of its own. It fires when an authenticated client calls the gateway, using the bearer gate the gateway already has — the same one the CLI and SDKs authenticate with. The gateway checks the caller is authenticated, then binds the run under the trigger's own owner, so firing a trigger never widens what the caller could do directly. Use it when the caller is something you already trust and already talks to the runtime, and you do not want to open a second port.

kx triggers add --name ingest --kind grpc --recipe kx/recipes/chat --enabled
kx triggers fire --name ingest --payload '{"prompt":"process the queue"}'

Holding irreversible actions for a person

An unattended run has nobody watching it. --require-approval puts a gate in front of any world-mutating action the run's agentic step wants to take: read-only steps proceed on their own, and anything irreversible waits for an operator to grant it.

kx triggers add --name standup --kind cron --app standup-digest \
  --cron "0 9 * * 1-5" --timezone America/New_York \
  --require-approval --enabled

The flag is stamped onto the run when the target is an app. On a recipe target it is recorded on the trigger but does not change the run's posture — a recipe-target run uses the serve-wide approval setting instead.

Turn this on for any scheduled or webhook-driven app that can post, send, delete or pay. See Approvals for how a pending grant reaches you and how you answer it.

An app-target trigger runs under the identity of whoever registered it — the same party that saved the app and registered its connections. A webhook caller can therefore start the run, but can never reach past what the registrant could do themselves. The trigger proposes; the runtime decides.

What cannot be a schedule target

A hosted app — the kind you open in a browser — carries no runnable plan. It is served, not scheduled. Passing one as --app is refused at registration with a message saying so, rather than silently accepting a trigger that would fail on every fire. Schedule a functional app instead. On a serve where the app catalog is not wired, that check cannot run at registration and the refusal happens on the first fire instead.

An app target also needs a runtime with the app-run path built in — the non-default mcp-gateway feature, with the connections database your connections are registered against. If it is missing, registration fails immediately rather than at 09:00 on a Monday.

Managing what you have

kx triggers list
kx triggers rm --name alert

list prints one line per trigger: its name, its kind, its target (an app target shows as app:<handle>), the auth posture, whether it is enabled, whether an auth secret is attached — never the secret itself — the schedule and timezone, and hitl when approval is required. Add --json for the same row as data, including the timestamp of the last fire.

When it does not fire

What you seeThe usual cause
Nothing ever runsthe trigger was registered without --enabled
Every POST returns 401the trigger is disabled, the name is wrong, the signature was computed over reformatted bytes, or none auth is being used off loopback
413the body is over the 256 KiB cap
429the per-trigger rate limit; slow the sender down
"deduped": true when you wanted a new runthe same idempotency key, or a byte-identical payload with no key
Registration rejected the schedulea malformed crontab expression, a zero interval, or a timezone that is not an IANA name
A cron trigger registers but every fire failsthe target needs a parameter, and a tick sends an empty payload — schedule an app instead