Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ are released under AgentOS's repository license (Apache-2.0; see `LICENSE`):

- `agentos`
- `cron`
- `cron-watchers`
- `deep-research`
- `docx`
- `git-diff`
Expand Down
36 changes: 36 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,42 @@ agentos cron status <job-id>
agentos cron runs <job-id>
```

`--job-kind` picks what fires: `reminder` (delivers `--text` verbatim, no model),
`script` (runs a file, no model), `agent_turn` (the agent runs `--text` as a
prompt), or `system_event`. It defaults to `auto`, which is `reminder` for normal
targets — so the example above repeats that sentence hourly rather than
summarizing anything. Add `--job-kind agent_turn` to have the agent do the work.

### Running a script on a schedule, without a model

```sh
agentos cron add --every 5m --script watch-memory.sh --name memory-watchdog
agentos cron update <job-id> --script watch-disk.sh --workdir /srv/app
agentos cron add --every 15m --script watch_rss.py --name hn \
--script-arg --url --script-arg https://news.ycombinator.com/rss
```

`--script` implies `--job-kind script` and resolves relative to
`~/.agentos/scripts/`; absolute paths, `~`, and `..` are refused, and so is a
symlink out of that directory. `.sh`/`.bash` run under bash, anything else under
python. `--script-arg` (repeatable) passes argv straight to the script — never
through a shell. Non-empty stdout is delivered verbatim, empty stdout is a silent
run, and a non-zero exit or `--timeout` delivers the error and fails the job.
Secrets are masked in the output, and the gateway token is withheld from the
child process. The bundled `cron-watchers` skill ships scripts for RSS, JSON
endpoints, and GitHub repos that already follow this contract.

Add `--script` to an `--job-kind agent_turn` job instead and it becomes a
pre-run collector: its stdout is handed to the agent as context, and a tick
where it prints nothing skips the turn entirely — no model call at all. See
[`scheduling.md`](scheduling.md).

No model runs, so no tokens are spent — but nothing reviews the script before it
executes either. It runs on this host as you, unattended, so treat
`~/.agentos/scripts/` as trusted as your shell profile. Only an interactive CLI
or Web caller can create one; the in-agent `cron` tool refuses `job_kind='script'`
from a channel.

### Letting a cron job run shell-based skills

A cron turn runs under a read-only tool allowlist, so a job that is shown a
Expand Down
127 changes: 127 additions & 0 deletions docs/scheduling.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,128 @@ agentos cron add \
--name launch-checklist-reminder
```

## Job Kinds

`--job-kind` decides what actually happens when a job fires. It defaults to
`auto`, which resolves to `reminder` for normal targets, `system_event` for
`--session-target main`, and `script` when you pass `--script`.

| Kind | What fires | Spends tokens |
| --- | --- | --- |
| `reminder` | Your `--text` is delivered verbatim. Nothing else runs. | No |
| `script` | A file in `~/.agentos/scripts/` runs; its stdout is delivered. | No |
| `agent_turn` | The agent runs `--text` as a prompt and its reply is delivered. | Yes |
| `system_event` | The text is written into the main session and wakes the heartbeat. | Yes |

An `agent_turn` job can also carry a `--script`, which then runs *before* the
turn as a data collector — see [Pre-run scripts](#pre-run-scripts).

The default matters: `agentos cron add --every 1h --text "Summarize updates"`
creates a **reminder**, so it repeats that sentence every hour rather than
summarizing anything. Add `--job-kind agent_turn` when you want the agent to do
the work.

## Run a Script Instead of a Model

A `script` job is the watchdog shape — poll something on a timer, deliver a line
when it matters, stay quiet otherwise — with no model in the loop:

```sh
mkdir -p ~/.agentos/scripts
cat > ~/.agentos/scripts/watch-memory.sh <<'EOF'
#!/usr/bin/env bash
used=$(ps -A -o %mem | awk '{s+=$1} END {printf "%d", s}')
[ "$used" -gt 90 ] && echo "⚠ memory at ${used}%"
EOF

agentos cron add --every 5m --script watch-memory.sh --name memory-watchdog
agentos cron update <job-id> --script watch-disk.sh
```

The script path is **relative to `~/.agentos/scripts/`**. Absolute paths, `~`,
and `..` are refused, and a symlink that leaves the directory is refused at run
time — the directory is the trust boundary. `.sh` and `.bash` run under bash;
every other extension runs under the same Python interpreter as the gateway.
Pass `--workdir` to run somewhere other than the script's own directory.

Arguments go through `--script-arg`, repeated once per argument:

```sh
agentos cron add --every 15m --script watch_rss.py --name hn-watch \
--script-arg --name --script-arg hn \
--script-arg --url --script-arg https://news.ycombinator.com/rss
```

They are passed to the script as argv with no shell in between, so a value
containing spaces or `;` stays one argument and cannot start a second command.
The Web UI takes them as one line and splits it the way a shell would.

The bundled **cron-watchers** skill ships ready-made scripts for the common
sources (RSS/Atom, a JSON endpoint, a GitHub repo) that already follow this
contract — ask the agent for it, or see
`agentos skills view cron-watchers`.

What the job does with the result:

- **stdout** → delivered verbatim, exactly as printed (capped at 16k characters).
- **no stdout** → silent run. Nothing is delivered and the run counts as a
success, so a watchdog that prints only on trouble stays quiet.
- **a final line of `{"wakeAgent": false}`** → also treated as silence, so
watchdog scripts written for other runtimes work unchanged.
- **non-zero exit or timeout** → the error is delivered *and* the job fails, so
a broken watchdog cannot be mistaken for a quiet one. `--timeout` bounds the
run (default 600s).

Secrets are masked in both stdout and stderr before delivery, and AgentOS's own
controls (`AGENTOS_GATEWAY_TOKEN` and the redaction/sensitive-path switches) are
withheld from the child process. Provider credentials are *not* — a script
inherits `OPENAI_API_KEY` and friends exactly like every other AgentOS child
process, which is what lets a watcher call a model API of its own. Set
`AGENTOS_STRIP_PROVIDER_ENV=1` to withhold those too.

**What you are accepting.** The script runs on this host as you, on schedule,
with nobody watching and no approval prompt — there is no model deciding what to
run, but also nothing reviewing it. Treat `~/.agentos/scripts/` as trusted as
your shell profile, and remember that anything with write access to that
directory can schedule itself. For that reason a script job can only be created
by an interactive CLI or Web caller: the in-agent `cron` tool refuses
`job_kind='script'` from a channel, which keeps a chat message from scheduling
unattended execution.

Script jobs never take `--elevated` — elevation only means something for a job
that runs an agent turn.

## Pre-run Scripts

The other half of the same idea: keep the script, but let an agent read what it
found instead of the user. Add `--script` to an `agent_turn` job and it runs
first, as a data collector:

```sh
agentos cron add --every 10m --name repo-triage \
--job-kind agent_turn \
--script watch_github.py \
--script-arg --repo --script-arg owner/name \
--script-arg --scope --script-arg issues \
--text "Summarize anything here that looks urgent. Stay brief."
```

Per tick:

- **stdout** → prepended to the prompt as `## Script output`, then the turn runs
with your `--text` after it.
- **no stdout** (or a `{"wakeAgent": false}` final line) → the turn is skipped
entirely. No model call, no session, no transcript line, no delivery. This is
what makes the pattern cheap: the agent only wakes on ticks with news.
- **non-zero exit** → the error is prepended as `## Script error` and the turn
runs anyway, so the agent can tell the user the collector broke.

Same directory rule, same argv handling, and the same operator gate as a script
job. The script's stdout is untrusted input arriving inside a prompt — the
header says so to the model, and a cron turn's read-only tool surface is the
real containment. Think twice before combining a pre-run script that reads the
open internet with `--elevated`.

## Choose the Session Target

The default target is an isolated session. For most scheduled work, that is the
Expand Down Expand Up @@ -163,6 +285,11 @@ If a job posts to a channel, also check:
agentos channels status
```

A `script` job that appears to do nothing is usually working as designed: empty
stdout means silence. `agentos cron runs <job-id>` distinguishes the two — a
silent run is recorded with a `silent: script produced no output` summary, while
a broken script is recorded as a failure with the exit code and stderr.

Read next:

- [`channels.md`](channels.md)
Expand Down
23 changes: 23 additions & 0 deletions frontend/src/views/cron/CronPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,29 @@ describe('CronPage', () => {
)
})

it('renders a script job with the Script pill and its script path', async () => {
wireRpc({
jobs: [
{
id: 'job-script',
name: 'Memory watchdog',
enabled: true,
expression: '*/5 * * * *',
payloadKind: 'script',
sessionTarget: 'isolated',
script: 'watch-memory.sh',
message: 'watch-memory.sh',
},
],
})
renderPage()
await waitFor(() => expect(screen.getByText('Memory watchdog')).toBeInTheDocument())
const card = screen.getByLabelText('Cron job Memory watchdog')
expect(within(card).getByText('Script')).toBeInTheDocument()
expect(within(card).getByText('watch-memory.sh')).toBeInTheDocument()
expect(within(card).queryByText(/elevated/i)).toBeNull()
})

it('shows the elevated badge on the card, without opening the editor', async () => {
wireRpc({ jobs: [{ ...AGENT_JOB, elevated: 'bypass' }] })
renderPage()
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/views/cron/CronPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,11 @@ export function CronPage() {
const enabledCount = jobs.filter((j) => j.enabled).length
const paused = total - enabledCount
const upcoming = jobs.filter((j) => isUpcomingRun(j)).length
const reminders = jobs.filter((j) => (j.payloadKind || j.payload_kind) === 'reminder').length
// Reminders and scripts share a tile: both deliver without spending a token.
const reminders = jobs.filter((j) => {
const kind = j.payloadKind || j.payload_kind
return kind === 'reminder' || kind === 'script'
}).length
const agentTasks = jobs.filter((j) => (j.payloadKind || j.payload_kind) === 'agent_turn').length

// cron.js:562-570 — filter then sort.
Expand Down Expand Up @@ -707,7 +711,7 @@ export function CronPage() {
value={upcoming}
hint={upcoming ? 'scheduled ahead' : 'no upcoming runs'}
/>
<StatTile label="Reminders" value={reminders} hint="static reminders" />
<StatTile label="Reminders" value={reminders} hint="reminders & scripts" />
<StatTile label="Agent tasks" value={agentTasks} hint="scheduled turns" />
</div>
</section>
Expand Down
92 changes: 82 additions & 10 deletions frontend/src/views/cron/CronPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const SCHEDULE_TYPES: Array<{ value: ScheduleKind; label: string }> = [
// cron.js:130-134 — job mode options.
const JOB_MODES: Array<{ value: PayloadKind; label: string }> = [
{ value: 'reminder', label: 'Static Reminder (no model)' },
{ value: 'script', label: 'Script (no model)' },
{ value: 'agent_turn', label: 'Background Agent Task (choose session)' },
{ value: 'system_event', label: 'System Event (Main)' },
]
Expand Down Expand Up @@ -147,6 +148,11 @@ export function CronPanel({
// cron.js:997-1057 — the resolved session target (locked flags + message label).
const targetRes = resolveTarget(form.payloadKind, form.sessionTarget, activeSessionKey)

// A script job's script IS the job (required); an agent turn may add one as
// an optional pre-run collector. No other kind runs a file.
const isScriptJob = form.payloadKind === 'script'
const canRunScript = isScriptJob || form.payloadKind === 'agent_turn'

const isAnnounce = form.deliveryMode === 'announce'
const isWebhook = form.deliveryMode === 'webhook'
const showBestEffort = isAnnounce || isWebhook
Expand Down Expand Up @@ -350,16 +356,82 @@ export function CronPanel({
</label>
) : null}

<label className="cron-field">
<span className="t-label">{targetRes.messageLabel}</span>
<textarea
className="cron-input cron-input--textarea"
rows={4}
placeholder="Run daily report…"
value={form.message}
onChange={(e) => set('message', e.target.value)}
/>
</label>
{isScriptJob ? null : (
<label className="cron-field">
<span className="t-label">{targetRes.messageLabel}</span>
<textarea
className="cron-input cron-input--textarea"
rows={4}
placeholder="Run daily report…"
value={form.message}
onChange={(e) => set('message', e.target.value)}
/>
</label>
)}

{canRunScript ? (
<>
<label className="cron-field">
<span className="t-label">{isScriptJob ? 'Script' : 'Pre-run script'}</span>
<input
className="cron-input"
type="text"
autoComplete="off"
spellCheck={false}
placeholder={isScriptJob ? 'watch-memory.sh' : '(optional) watch-rss.py'}
value={form.script}
onChange={(e) => set('script', e.target.value)}
/>
<span className="cron-field__hint">
Relative to ~/.agentos/scripts/. .sh and .bash run under bash, anything else under
python.{' '}
{isScriptJob
? 'Its stdout is delivered as-is; no output means nothing is sent.'
: 'Its stdout becomes context for the turn; no output means the turn is skipped.'}
</span>
</label>

<label className="cron-field">
<span className="t-label">Script arguments</span>
<input
className="cron-input"
type="text"
autoComplete="off"
spellCheck={false}
placeholder="--url https://example.com/feed.xml"
value={form.scriptArgs}
onChange={(e) => set('scriptArgs', e.target.value)}
/>
<span className="cron-field__hint">
Split the way a shell would, then passed straight to the script — never run
through a shell. Quote a value that contains spaces.
</span>
</label>

<label className="cron-field">
<span className="t-label">Working directory</span>
<input
className="cron-input"
type="text"
autoComplete="off"
spellCheck={false}
placeholder="(the script's own directory)"
value={form.workdir}
onChange={(e) => set('workdir', e.target.value)}
/>
</label>
</>
) : null}

{isScriptJob || (canRunScript && form.script.trim()) ? (
<div className="cron-field cron-elevated tone-danger">
<p className="cron-elevated__warning">
This script runs on this host as you, on schedule, with nobody watching and no
approval prompt. Nothing reviews what it does before it runs — keep the scripts
directory as trusted as your own shell profile.
</p>
</div>
) : null}

{form.payloadKind === 'agent_turn' ? (
<div className="cron-field cron-elevated tone-danger">
Expand Down
Loading
Loading