How to run autonomous AI agents unattended, open source end to end
Autonomy is four conditions rather than a switch: something real starts the run, the run has a branch of its own, every tool call is ruled, and the result comes back as something a person can reject. Kortix is the open-source AI Operating System, and all four are files in the repo you own.
How do you set up an agent that runs with nobody watching?
A weekday digest, written into the manifest and added from a terminal.
triggers: - slug: daily-digest type: cron cron: "0 0 9 * * 1-5" timezone: Europe/Berlin prompt: Summarize yesterday's commits and open change requests.kortix triggers add daily-digest --type cron --cron "0 0 9 * * 1-5" --timezone Europe/Berlin --prompt "Summarize yesterday's commits and open change requests." --applyThe schedule is the easy part
Running an autonomous AI agent unattended is mostly a question of what you checked before you stopped watching. The trigger is a short entry in kortix.yaml: a type, a prompt, and the agent that runs it. What makes the run safe to leave alone sits around that entry, in the branch it works on, the calls it may make, and the form its output takes.
Kortix is the open-source AI Operating System: your agents, their skills, your company memory and every connector live in one git repo you own. Every setting here is a file with a diff history, so the run you leave going on Friday is the run you can read on Monday.
Four trigger types, and the evidence each one needs
Cron is what people mean when they say schedule. The other three exist because the clock is a poor proxy for "there is something to do".
| Type | Fires when |
|---|---|
| cron | A schedule slot arrives, or once at run_at. |
| webhook | A signed POST reaches the trigger's URL. |
| event | Something happens in a connected app, such as a new pull request or a new email. |
| monitor | A command from your repository prints a line on stdout. |
Choose by the evidence that already exists. A daily digest is about a period of time, so the calendar is the evidence and cron is enough. A form submission is a request, so the trigger is a webhook and it carries a secret. A new pull request is already known to the app that hosts it, so an event trigger reads that. A condition that lives in a command you already run, such as a queue depth, is a monitor, and the trigger watches stdout instead of the clock.
The types also fail differently, and that matters more than the fire itself. A cron trigger pointed at the wrong branch produces an empty summary and nothing else. A monitor whose command prints nothing starts nothing at all. An unsigned webhook, if it existed, would be a public button that starts a session on your repo, which is why the secret is not an optional field.
The expression is six fields, and the seconds field comes first
A cron trigger is the one type that needs nothing outside the repository. The daily-digest entry is the whole schedule: a slug, the type, the expression, the zone, and the prompt the session opens with.
A cron trigger takes a 6-field expression: second, minute, hour, day of month, month, weekday. A trigger fires at most once a minute. The seconds field arrives first, so a five-field expression carried over from another scheduler does not land where its author expects, because every value sits in a different position here. Read the six positions in order once and the rest of the string is ordinary.
timezone holds the zone the expression is read in, which is how 0 0 9 * * 1-5 lands at nine in the morning for the people who read the digest rather than nine somewhere else. A run that should happen once, rather than on a cadence, skips the expression and sets run_at to a single instant instead.
Either form ends in the manifest: a trigger is an entry under triggers: in kortix.yaml, or in a file listed under imports:. What differs is the route a change takes. A kortix.yaml edited in a session follows that session's branch and its change request. --apply, the web app, the API and the SDK commit to the default branch straight away, with no change request.
A webhook without a signature is a start button for anyone
A webhook trigger fires when a signed request reaches its URL. Every webhook trigger needs a signing secret. There is no unsigned webhook. That sentence is the whole security model of the type, and it is why the sender's job is smaller than it looks: post the body, compute a value, attach it.
The value is HMAC-SHA256 of the raw request body, keyed with the secret. Because the signature is computed over the raw body, a payload rewritten in transit no longer matches the value the sender computed. The reader on the other end does not need to trust the sender, only the shared secret.
Two things about that secret can be wrong before any request arrives, and both are handled on the server. Kortix keeps the signing secret server-side for the webhook receiver, stored in the connector scope with kortix secrets set WEBHOOK_SECRET=- --scope connector. A secret that already exists needs its matching delivery, kortix secrets delivery WEBHOOK_SECRET broker --consumer connector, because without that delivery the API refuses to create the trigger with a 409 and every request answers 401. The name still has to be listed under env.optional or env.required in kortix.yaml, or kortix validate and kortix ship report triggers[n].secret_env as an error.
Cap the runs before you leave
A trigger with no cap is a loop with an audience. The failure is rarely one bad run. It is a sender that retries, a monitor command that prints the same line every minute, or an event source that replays a batch after an outage, and each of those turns one session into forty while nobody is at the keyboard.
--max-runs-per-hour and --max-runs-per-day apply on every add and set, so the cap is a flag on the trigger itself. Set both at creation, when you still know what a normal hour looks like. The minute floor is the other half of the same idea: a cron trigger fires at most once a minute, so a schedule written for every thirty seconds does not queue up work, it simply cannot be expressed.
What the person on the other end receives
An unattended run ends somewhere a person can act. A session runs one agent in its own sandbox. The sandbox clones the repo and works on a new branch, so the default branch keeps its shape while the run is in progress and after it finishes.
Then the work travels. The agent commits, pushes the session branch, and opens a change request against the project's default branch. A person with the project.gitops.merge permission reviews the change request and merges it. The reviewer reads a diff and can reject it, and a rejection costs the default branch nothing because the work never left its branch. That last condition is what makes the run reviewable at all: what comes back is something a person can refuse.
The self-host failure that looks like a healthy schedule
Self-hosting adds one way for an unattended run to do nothing without saying so. An instance started in domain mode (--domain <domain>) or tunnel mode (--tunnel cloudflare) starts sessions. Local mode, started with neither flag, binds 127.0.0.1 and starts no session because the sandbox cannot reach the API. The trigger exists and reads correctly, and the fire produces nothing.
That failure is worth knowing because it is invisible from the outside. It also explains the shape of the checks worth running: each one asks what a person could confirm from the files, without waiting for a run to prove it.
The checks to run before you stop watching
- The trigger names a type you can point at, and the thing that fires it exists: a slot, a sender with the secret, a connected app, a command in the repo.
- The expression has six fields and a zone, and a one-off run uses
run_atinstead. - A webhook trigger has its signing secret, and the sender signs the raw body with it.
--max-runs-per-hourand--max-runs-per-dayare both set.- The merge sits with a person who holds the
project.gitops.mergepermission, and the tool rules are written down for the calls that cannot be undone.
None of these is about the schedule, which is the point. The clock is the part you configure in a minute. The four conditions around it are what you are actually trusting at 03:00.
Three pages go deeper on the pieces. What autonomous AI agents are sets out the three parts a definition needs to be checkable. Autonomous vs human in the loop places the person at one of three levels and shows what each costs. Autonomous agent examples shows the work these runs hand back, from a weekday digest to a monitor that opens a fix.
What holds a run you are not watching
Three settings to check before the first fire, and where each one lives.
Where the work landsa change request a person merges
Every tool callallow, ask or block
Connector credentialsnever enter the machine
Questions that come up before the first fire
Add a cron trigger with a daily expression. A cron trigger in Kortix takes a 6-field expression, second, minute, hour, day of month, month and weekday, so the weekday field is where the days get chosen and timezone names the zone it fires in.
A signed POST reaches the trigger's URL, and that fire starts a session. Every webhook trigger needs a signing secret, there is no unsigned webhook, and the signature is HMAC-SHA256 of the raw request body keyed with that secret.
Set --max-runs-per-hour and --max-runs-per-day on the trigger, and both apply on every add and set. A cron trigger also fires at most once a minute, so an expression finer than that buys nothing.
Onto a branch of its own, and then into a change request. A session runs one agent in its own sandbox, the sandbox clones the repo and works on a new branch, and the agent commits and pushes that branch.
Start with one trigger and one reviewer.
The trigger, the policy and the merge rule are all files you can open.