kortecxdocs
Apps

Scheduled Apps

Author an app, run it once by hand, then put it on a timetable with a cron trigger that fires it unattended in your own timezone.

A scheduled app is a piece of work you have already taught the agentic runtime to do, set to happen on its own at a time you choose. You write it once, watch it run once, and then hand it a timetable. After that it runs without you.

This page walks the whole lane end to end: author, run, schedule.

What you need first

A running gateway. Start one on your own machine with kx serve --dev-allow-local. Everything below talks to it. See Install if you do not have kx yet.

The steps in the example below are model steps, so the gateway also needs a model served to it — see Serving. The schedule itself lives inside the gateway, so the gateway process has to stay running for a scheduled app to fire.

The three pieces

An app is a saved, reusable unit of work — a kortecx.app/v1 envelope holding a blueprint (the steps and how they connect), by-reference pointers to the context, tools and connections it may use, and its steering settings. It carries no authority of its own: every time it runs, the runtime re-resolves what it is allowed to do from your grants.

A trigger is an inbound event bound to an app. There are three kinds — webhook, cron and grpc. A cron trigger is the one that carries a timetable.

The cron ticker is a task inside the gateway that wakes up every few seconds, looks for triggers whose scheduled moment has arrived, and fires them.

Author the blueprint

A blueprint is the shape of the work: the steps, and which step feeds which. The quickest way to write one is the chain DSL, where > means "then" and & means "at the same time".

Write a tasks.json describing each step:

{
  "draft": {
    "kind": "model",
    "prompt": "Write a short standup summary from yesterday's notes."
  },
  "review": {
    "kind": "model",
    "prompt": "Tighten the summary. Remove anything that is not a fact."
  }
}

Then lower it to a portable blueprint file without submitting anything:

kx chain run "draft > review" --tasks tasks.json --dry-run --emit-blueprint standup.blueprint.json

--dry-run compiles and validates the graph and needs no gateway. If a handle is unknown, or the graph has a cycle, it fails here rather than at run time.

Steps inside one app can fan out and fan back in — "plan > [a & b] > gather" runs a and b in parallel and merges them. If you would rather not write that topology yourself, kx swarm --pattern supervisor builds it and runs it directly (it runs a chain; it does not write a blueprint file for you). Chaining always happens inside one app; an app does not call another app.

Wrap it as an app and save it

kx app new wraps the blueprint in an envelope. It is offline — no gateway contact — unless you attach catalog skills with --skill.

kx app new standup --from-blueprint standup.blueprint.json --description "Daily standup summary" --output standup.app.json
kx app save standup.app.json

save validates and canonicalizes the envelope and stores it in your own catalog. The handle defaults to apps/local/standup — derived from the name, lowercased, with anything unusual turned into a hyphen. Pass --handle to choose your own.

Check it landed:

kx app list

Fill in the project files

An app can also have a small project tree — the written material behind it: what it is for, how it should behave, what it must refuse. kx app scaffold has the runtime author that tree for you, file by file, into the app's own copy-on-write branch. It needs a served model (Serving); without one the command reports that this gateway has no scaffold orchestrator.

kx app scaffold apps/local/standup --goal "summarise yesterday's notes into a standup update" --wait

For a scheduled app the structure is fixed. These five files are the skeleton:

README.md
app.json
system.md
guardrails.md
main.md

README.md says what the app does. app.json describes it as data. prompts/system.md is a system prompt for the app. rules/guardrails.md is the scope, safety and refusal rules. skills/main.md is the primary instruction plus the tools it may use.

List and read them:

kx app files apps/local/standup
kx app cat apps/local/standup rules/guardrails.md

The project tree is source, not wiring

These files live in the app's branch. A run folds in the prompts, rules, context and memory the envelope points at by reference — the envelope's branch handle is reserved and is not read at run time. So scaffolding a rules/guardrails.md does not by itself change what a run does; you still have to reference the text you want from the app itself. See Editing an app.

Run it once, by hand

Never schedule something you have not watched run.

kx app run apps/local/standup --wait

--wait blocks until the run reaches a terminal state and prints the result. Add --out result.txt to write the body to a file, and --arg key=value (repeatable) to pass inputs, which are folded into the app's entry model step as an "Inputs" block.

If the app can do something irreversible, run it the careful way first:

kx app run apps/local/standup --require-approval --wait

With that flag, a world-mutating tool call pauses instead of firing. You release it yourself:

kx approvals list
kx approvals grant <REQUEST_ID>

Read more in Approvals.

Put it on a timetable

Now bind a cron trigger to the app. This is the end state:

kx triggers add --name standup --kind cron --app apps/local/standup --cron "0 9 * * 1-5" --timezone America/New_York --require-approval
from kortecx import KxClient

with KxClient("http://127.0.0.1:50151") as kx:
    trigger_id = kx.triggers.add(
        "standup",
        kind="cron",
        app="apps/local/standup",
        schedule="0 9 * * 1-5",
        timezone="America/New_York",
        require_approval=True,
        enabled=True,
    )
    print(trigger_id)
import { KxClient } from "@kortecx/sdk";

const kx = new KxClient("http://127.0.0.1:50151");
const { triggerId } = await kx.triggers.add({
  name: "standup",
  kind: "cron",
  appHandle: "apps/local/standup",
  scheduleSpec: "0 9 * * 1-5",
  timezone: "America/New_York",
  requireApproval: true,
  enabled: true,
});
console.log(triggerId);
kx.close();

Read the schedule left to right: minute, hour, day-of-month, month, day-of-week. So 0 9 * * 1-5 is "at 09:00, Monday through Friday" — and --timezone America/New_York means 09:00 there, not in UTC. The timezone is an IANA name; leave it off and the schedule is evaluated in UTC.

On the CLI, a trigger is off until you enable it

kx triggers add registers the binding disabled unless you pass --enabled. A disabled trigger never fires, and kx triggers fire refuses it. Add the flag once you are ready for it to start firing, or register it first, dry-run it with kx triggers test (which works while it is disabled), then run kx triggers add again with the same --name plus --enabled — a re-add with the same name replaces the binding. The Python and TypeScript SDKs take the opposite default and register it enabled unless you say otherwise.

Exactly one of --app <handle> or --recipe <handle> is required. Use --app here: the app fires unattended with its declared connections and secret scope resolved.

--require-approval puts the same human gate on every scheduled run — a scheduled fire goes through the same authoring path as kx app run --require-approval. An unattended run that wants to do something irreversible will wait in kx approvals list for you rather than proceeding on its own.

Writing the schedule

The schedule field accepts two shapes, and the runtime tells them apart by whether it is all digits.

What you passWhat it means
--cron "0 9 * * 1-5"A standard 5-field crontab expression, evaluated in --timezone
--cron 300A plain interval: fire every 300 seconds

--schedule is an alias for --cron, and --tz is an alias for --timezone. Both name the same field.

Some things worth knowing before you write one:

  • Exactly five fields. A 6-field expression with a seconds column is rejected.
  • Daylight saving is handled. The next fire time is computed in the named zone with DST-correct arithmetic, so a 09:00 job stays at 09:00 across the change.
  • Strictly forward. The next fire is always computed as strictly after now, so a job that is due at this exact instant schedules for its next occurrence, never the one happening now.
  • An interval must be above zero. --cron 0 is rejected.
  • A typo fails immediately. An unparseable expression, or a timezone that is not a real IANA name, is rejected when you register the trigger — not silently turned into a trigger that never fires.

A duplicate tick fires once

The gateway does not just call your app on a timer and hope. Each cron trigger keeps a watermark: the timestamp of the moment it is next due.

On each tick the ticker advances that watermark first, before it fires anything, so neither a slow start nor the following tick can pick the same moment up twice. It then fires with an idempotency key derived from the trigger's name and that exact watermark. If the same scheduled moment somehow reaches the runtime twice — a retry, a race — the second one is recognised as the same event and returns the run the first one already started. One scheduled moment, one run.

What this does not promise

The ticker scans for due triggers every few seconds, so a run starts close to its scheduled minute rather than exactly on it. And if a fire itself fails, that is one missed run — the next scheduled time fires normally. The local scheduler makes no exactly-once delivery claim; what it guarantees is that a duplicate or raced tick does not double-fire. The gateway also has to be running at the scheduled moment: a gateway that is stopped at 09:00 does not fire that occurrence.

The watermark is stored outside the journal, so restarting the gateway re-reads it and picks the schedule back up where it was.

Checking and changing a trigger

Dry-run the binding without firing anything — this checks the handle resolves and the payload binds:

kx triggers test --name standup

See everything registered, with its kind, target and whether an auth secret is attached:

kx triggers list

Fire one on demand, out of schedule (the trigger has to be enabled):

kx triggers fire --name standup --idempotency-key manual-2026-07-20

The idempotency key is yours to choose. Replaying the same key returns the run the first one started rather than making a second one. Leave it off and the runtime derives one from the payload.

Remove it:

kx triggers rm --name standup

When a scheduled run goes wrong

Nobody is watching at 09:00. So the runtime keeps the record for you.

  • Run history. kx runs list shows durable runs newest-first, scheduled ones included.
  • Failures. Terminal failures land in an inbox instead of vanishing — kx alerts list, or kx alerts list --instance <INSTANCE_ID> for one run (the run's 16-byte instance id, written as hex).
  • Spend. kx cost <INSTANCE_ID> gives a local spend estimate at rates you configure, with a ceiling — not a bill.
  • Metrics. There is an opt-in Prometheus /metrics endpoint if you want to graph it. See Observability.

Notes and limits

  • A trigger runs under your party. The trigger id is derived by the server, and the run binds to the identity of whoever registered it — never to something the client claims.
  • Secrets are referenced by name only. --secret-ref names a secret in the local store; the value never travels over the wire.
  • Scheduling is single-system by default. One gateway owns its triggers; running a schedule across multiple nodes is roadmap, not today.
  • A cron trigger fires the app with an empty payload. If your app needs inputs, put them in the app itself rather than expecting them from the tick.
  • Only a functional app can be scheduled. A hosted app is refused at registration.