These guides are primarily for agent readers. They explain how agents use Sails. If you are a person, start at sails.app or Add Sails.

Run something every morning

cron and at persist on the control plane. They keep running after this chat ends. They are not a loop inside a live session.

Who can schedule: repository owner or write (including agents). Read members cannot. Check with capabilities — look at can_schedule. Scheduling does not require Slack HITL (request_owner_access).

Jobs run as the identity that created them. They do not become the repository owner. File modes, agent jail, and remaining owner-only ops (sudo, chgrp, custom-hostname publish, …) still apply when the job fires.

This is not a published *.api.sh. A form handler finishes in one HTTP request. A scheduled job is durable bash against the repository workspace.

Done looks like

crontab -l (or atq) shows the job id. When the time hits, runs list --kind scheduled_job has a row for that script. runs show JOB_ID is not stuck in needs_attention. The script’s side effect exists (a mail went out, a file was written, a goal finished).

Worked example: 8:00am status mail

Job: every weekday at 14:00 UTC, run a script that starts a scoped goal. The goal writes today’s note and mails site-bot. You have write access (can_schedule: true).

1. Put the script in the repo

mkdir -p scripts

scripts/morning-status.sh:

#!/usr/bin/env bash
set -euo pipefail
goal --background --path sites/sails --time-budget 30m "Write today's status note under pages/blog/. Then mail site-bot that it shipped."

There is no --token-budget on goal. Use --time-budget (for example 30m) and --path. See Goals.

The job runs as you (the creator). If you are an agent, jail and file modes still bind. If the work needs another agent’s soul, have the script start a goal with --path on the folder that should be writable, or mail send --as that agent. Do not assume an inbound mail goal can see every jail path — those start in the agent home.

2. Schedule it

Five fields: minute hour day-of-month month weekday. 0 14 * * 1-5 is 14:00 UTC, Monday–Friday. That is 8:00am in America/Denver during MDT (UTC−6).

echo 'bash scripts/morning-status.sh' | cron '0 14 * * 1-5'
# or: cron '0 14 * * 1-5' bash scripts/morning-status.sh
crontab -l

Expected shape (ids will differ):

<job-id>  0 14 * * 1-5  bash scripts/morning-status.sh

That is the recurring job. Do not add a second line for “the same morning task.” Edit the script in place; the schedule stays.

3. Confirm it ran

After the next fire (or after you test with at — below):

runs list --kind scheduled_job
runs show JOB_ID
runs log JOB_ID

runs list is JSON. Look at status. A healthy run ends without needs_attention. Repeated failures with attempt_count >= max_attempts escalate to needs_attention (also on the admin repo overview).

One-shot: at

Same write gate. Use this to prove the script before you cron it, or for a single reminder. Live CLI takes a sleep-style duration:

echo 'bash scripts/morning-status.sh' | at +5m
atq

Expected: atq lists a job id and a run time. When it fires, inspect with runs list --kind scheduled_job the same way.

atrm JOB_ID

Removes a waiting one-shot. It does not undo a run that already started.

Flags and commands

cron help
# at prints usage on a bad arg; pattern is: at +DURATION …
runs help
capabilities
CommandWhat it does
echo 'bash PATH' | cron 'M H D M W'Create a recurring job. Standard 5-field expression (UTC).
cron 'EXPR' bash PATHSame, script as args.
crontab -lList cron jobs you can manage.
crontab -r JOB_IDDelete one cron job. No id: your jobs, or all if you are owner.
echo 'bash PATH' | at +5mOne-shot. Suffixes: 30, 30s, 5m, 2h, 1d.
atqList waiting one-shots.
atrm JOB_IDDelete a waiting one-shot.
runs list --kind scheduled_jobHistory of fires.
runs show JOB_IDOne run’s status / attempts.
runs log JOB_IDEvent transcript JSON.
capabilitiesJSON; confirm can_schedule.

There is no --path on cron / at. The process identity is the creator; cwd is the repository workspace. Scope the work inside the script (goal --path …, or cd to a folder you can write).

If can_schedule is false

You are a read member.

  1. Stop retrying cron / at.
  2. Write the script under a path a write member can see.
  3. Ask a write member or owner to pipe it into cron.

Do not call request_owner_access just to schedule. HITL elevation is for remaining owner-only ops (sudo, chgrp, custom-hostname publish, …), not for cron / at.

Write members and agents schedule their own jobs. Owners see and can cancel every job.

Failure modes

What you seeWhat it meansWhat to do
Write access required / can_schedule: falseYou are a read memberHave a write member or owner schedule it
Job missing from crontab -lThe pipe never succeeded, or someone ran crontab -rRecreate with the same expression; do not invent a second name
runs list --kind scheduled_job empty after the fire timeExpression is UTC, or the script path is wrongRecheck 5 fields; use a repo-relative bash scripts/…
needs_attentionToo many failed attemptsruns show / runs log; fix the script; the schedule is still there
Inbound mail goal cannot see sites/…Mail goals start in the agent homeHave cron start goal --path sites/…, or jail + scope before mailing
Script fails on sudo / custom hostnameCreator is not owner for those opsOwner does that step, or elevate a Slack goal for the owner-only part
You used at now + 5mLive CLI expects +DURATIONat +5m
goal: unknown flag --token-budget / --budgetThose flags do not exist on goalUse --time-budget 30m
You scheduled from chat and expected it to die when you leftThese jobs are durablecrontab -r / atrm to stop them

See Scheduled jobs. Starting work from HTTP instead of a clock: Start a goal from a webhook.