Schedules
Takeaways:
- A schedule runs a plugin against a repo on a cron, with no CI or GitHub Actions config needed.
- The primary way to create one is a
schedulesblock in yourtessl.json. Push to your default branch and Tessl syncs it. - An unparseable or invalid file never deletes anything. Removing a schedule from a valid file is what deletes it.
- Run
tessl schedule validatebefore you push to catch errors locally. - When repo-defined schedules are enabled for your organization, you can pause and resume them in the web app and CLI. Change the file to change anything else.
What is a schedule?
A schedule runs a plugin on a cron. You pick a plugin, a repo, and a time, and Tessl runs the plugin against that repo on that schedule. Each time the schedule fires, Tessl starts a cloud run and reports the result back so you can see what happened.
Tessl skips a tick if the previous run from the same schedule is still active when the next one would start. There is no queue: a skipped tick does not run later.
Think of it as two parts working together. The plugin describes the task: it tells the agent what to do. The schedule describes the timing: it tells Tessl when to do it.
A schedule is one kind of trigger. The same action can also start from a GitHub or Linear event, or by hand, and every trigger that names it belongs to one loop. See Automations for how loops, actions, triggers, and runs fit together.
What you need first
- A Tessl account with a workspace.
- GitHub connected to your workspace. See Connect Tessl to GitHub.
- A plugin that describes the task. See Create a plugin.
- An environment, if the plugin needs secrets or has to push to your repo. Without one, a run gets a read-only GitHub token, which can check out your repo but can't open PRs. See Environments. For an organization environment, the schedule's project must be bound to it.
Define a schedule in tessl.json
The primary way to create a schedule is to declare it in your project's tessl.json. Add a schedules block, push it to your default branch, and Tessl reads the file and creates the schedule for you. The schedule lives next to the code it runs against, so it is reviewed and version-controlled like any other change.
The name field helps Tessl resolve the manifest to a project. Use the workspace/project format, for example "name": "my-org/my-app". Before schedules sync, an active project must exist in that workspace, be connected to the same GitHub repository, and use the manifest's directory as its repository subpath. The GitHub App installation must currently have access to the workspace. When Tessl resolves that binding, schedules from the manifest appear under the project's Schedules tab in the web app. Schedule sync does not create or reassign projects.
Tessl discovers and syncs each tessl.json in a repository independently. In a monorepo, a manifest such as apps/backend/tessl.json manages only its own schedules, without affecting the root or sibling manifests. Deleting that subdirectory manifest removes only the schedules managed by that manifest in workspaces that the GitHub App installation can still access. Schedules in workspaces it can no longer access are not reconciled by that deletion.
Let the schedule-setup skill do it for you
If you'd rather describe the schedule in plain language than write the JSON yourself, install tessl/schedule-setup.
When invoking the skill, tell your agent what you want to run and when ("run the docs audit every Monday at 9am London time using the production environment"). The skill asks clarifying questions one at a time, picks the most readable schedule format, writes and validates the entry, and explains what happens on push. It can also author a new repo-local skill for your schedule to use if you don't have one yet.
The schedules block
schedules is a map from each entry's name to the entry. The name is the entry's stable identity: a lowercase kebab-case slug, not a display label. See Behaviors to know for what renaming an entry does.
Each entry names what it runs with action and when it runs with one timing field. Use either cron or its alias, schedule, but not both. Beside action, an entry carries only its timing and its description:
An inline action takes these fields inside action. Setting any of them beside action is an error:
To run the same action from more than one schedule, declare it once in the manifest's top-level actions block and name it from each entry. A declaration takes the same fields as an inline action, and its name follows the same rule as a schedule entry's name: a lowercase kebab-case slug. "action": "pr-report" runs the declared action as written. "action": { "use": "pr-report", ... } runs it with overrides: each field you give replaces the declared one, null removes any field except skill, and inputs merges by input name, so an input you set to null is removed and "inputs": null removes them all.
You may also see entries in the flat form, which writes the action's fields directly beside the timing, with no action. A flat entry still runs, as the inline action its fields describe. It needs both skill and environment, and it can't set effort. To convert one, move its action's fields into action; it stays the same schedule with the same run history.
Raw cron expressions have five fields: minute, hour, day of month, month, and day of week. 0 9 * * 5 means "at 9:00 on every Friday". 0 8 * * * means "at 8:00 every day".
You can also use these shorthand strings:
hourlydaily at HH:MMweekdays at HH:MMweekly on DAY at HH:MMmonthly on Nth at HH:MMevery N minutesorevery N hours, whenNdivides evenly into 60 or 24every N minutes between HH:MM-HH:MM; the start is inclusive and the end is exclusive, and multi-hour windows use whole-hour endpointsevery N minutes on weekdays
For schedules that don't fit those patterns, use an object with minute, hour, day, month, and weekday fields. Omitted fields mean every value. Numeric fields accept a number, a list of numbers, { "every": N }, or { "between": [N, M] }. month and weekday also accept names or lists of names.
Tessl compiles shorthand strings and objects to cron before validation and storage. The timezone and 5-minute minimum apply to every input form.
Choose settings from an action's inputs
A declared action can pick its run settings from the inputs a run passes it. It declares named overlays, a selectOverlays list that renders to overlay names, and a params block that says which inputs are optional or read by the action only:
A trigger that starts it maps ESTIMATE from its event, for example "inputs": { "LINEAR_ISSUE_IDENTIFIER": "{{ event.data.identifier }}", "ESTIMATE": "{{ event.data.estimate }}" }, and tessl action trigger implement --input ESTIMATE=1 passes it by hand. Both get the same settings.
- Overlays: each overlay takes the same fields as a
useoverride, excepttagsandconcurrency, and follows the same rule: a value replaces the action's,nullremoves it, andinputsmerges by name. An overlay'sinputsreach the skill but never concurrency keys, which render from the inputs the run is given before overlays apply. - Selectors: each entry in
selectOverlaysis rendered against the inputs the run is given, the action's own with its starter's merged over them. When the result is exactly the name of an overlay, that overlay applies; otherwise the selector applies nothing. There are no patterns or wildcards. An overlay's owninputsnever feed selection. A selector without{{ }}must name an overlay the action declares. - Limits: an action declares at most 1,000 overlays and gives at most 100 selectors.
tessl doctor --automationsandtessl schedule validaterefuse a manifest over either limit. On push, Tessl holds theactionsblock of such a manifest and keeps its previous actions, and the manifest's other blocks still sync. - Order: a run's settings are the action's own, then the selected overlays in selector order, then the
usefields of the trigger or schedule that started it, then the fields of the request (for exampletessl action trigger --model). A later value wins. - Params: an input declared in
paramswithoutoptional, such as"string", is required: a run is refused, naming the input, when it has no value, whether it was never passed or its mapping names an event value that is null or absent, and whether or not any template reads it. An input with noparamsentry reaches the plugin as before, and refuses a run only when a template reads it and it has no value. Anoptionalinput with no value renders as empty text wherever a template reads it and is left out of the plugin's inputs. AnactionOnlyinput never reaches the plugin.
Only an action declared in actions takes these fields; an action written inline in a trigger or schedule is refused if it sets one. Runs that start with the same settings after overlays are applied are the same action, so their events can fold into one run.
A complete example
This declares two schedules. weekly-changelog runs the changelog-writer plugin every Friday at 9:00 New York time. api-docs-audit runs the docs-audit plugin every Monday at 8:00 UTC, steered at the API reference by the instructions in its action.
Behaviors to know
A schedule's identity is its entry's name. Renaming an entry with an inline action is therefore a delete and a create, not an update. For example, changing nightly-scan to nightly-check deletes the old schedule and creates a new schedule with the new name. Prior launch runs are preserved but are no longer linked to a schedule. If the new entry fails validation, neither the deletion nor the creation is applied.
Entries that name the same declared action show up in the web app and CLI as one schedule, named after the action. That schedule lists each entry's timing and keeps one run history for all of them, so renaming one of those entries keeps its runs where they are. Moving an entry from an inline action to a declared one, or back, or renaming the declared action, moves the entry to a different schedule, and its earlier runs don't carry over.
The schedules block is the source of truth. Removing the block from tessl.json, or setting it to {}, means “no schedules” and deletes all schedules managed by that manifest. To recover from an accidental removal, revert the commit and push again.
Validate before you push
Run tessl schedule validate before you push to catch errors locally. You can also run the validator in your CI workflow:
It reads your tessl.json, checks the schedules block and the actions block, and reports every problem against the schedule entry or action it belongs to. It exits non-zero when it finds a problem, so it also works as a CI check. A file with neither block is valid, and a file with no schedules block is valid as long as its actions block is.
On its own, validate checks the file: its shape, its cron expressions, the minimum interval, that every action a schedule names is declared, that each declaration in the actions block is valid, and that each repo-local file: skill a schedule runs exists in your working tree. It reports an actions problem against the action, as in actions.pr-report, so you can tell it from a schedule entry.
When you're signed in and the manifest's name identifies a project, validate also runs the checks Tessl applies when the file syncs and when a run starts: that the plugin resolves and its inputs match, that the named environment exists and can give the run a GitHub token, that your organization can use the agent, model, and effort, and that the workspace has room for the schedules. Otherwise it tells you these checks were skipped.
How the sync works
When you push to your default branch, Tessl reads tessl.json at the pushed commit and reconciles the schedules of the project its name identifies:
- An entry with an inline action is its own schedule. Adding the entry creates the schedule, changing it updates the schedule, and removing it deletes the schedule.
- An entry that names a declared action adds its timing to that action's schedule. Adding, changing or removing the entry updates the action's schedule, which stays as long as the
actionsblock declares the action.
Tessl applies the schedules block as a whole. If any entry fails validation, names an action the actions block doesn't declare, or points at a repo-local skill missing from the pushed commit, or if the block would take the workspace over its limit on active schedules, none of the block's changes apply and every schedule stays as it was. Tessl checks the environment, plugin, agent, and model each time a run starts: if one is missing or unavailable, that run doesn't start, Tessl records why on the schedule, and the schedule stays active for its next run.
Tessl reads the name field, the schedules block, and the actions block. A schedule that names a declared action picks up a change to that action on the next push, even when the push changes only the actions block. Changes to other parts of tessl.json don't change your schedules. Most pushes that don't touch tessl.json are ignored. A force push, branch creation, or push with an empty or truncated commit list bypasses that optimization, and Tessl reads the manifest at the pushed revision.
The sync runs off the default branch only. A schedule declared on a feature branch does not sync until the change reaches your default branch.
The safety model
Tessl never deletes a schedule because it could not understand your file. Deletion only ever follows from a file it read cleanly.
- Unparseable or invalid: nothing changes. If the file is malformed JSON or cannot be read, Tessl makes no changes at all. If the
schedulesblock fails validation, every schedule stays exactly as it is. If theactionsblock fails validation, the declared actions stay as they are, and so does every schedule that names one; schedules with an inline action still sync. A typo in either block doesn't delete your schedules. Fix the file and push again. - Valid and read cleanly: the file wins. Once Tessl reads a valid file, the file is the source of truth. Remove an entry with an inline action and its schedule is deleted. Remove an entry that names a declared action and only its timing is removed from the action's schedule. Remove the whole
schedulesblock, or delete atessl.json, and every repo-defined schedule managed by that manifest is deleted.
So the only way to delete a repo-defined schedule is to remove it from a valid file and push. An error in the file is always safe.
Pause and resume in the web app and CLI
When repo-defined schedules are enabled for your organization, a schedule defined in tessl.json can be paused and resumed from the web app (Pause and Resume on its page), the CLI (tessl schedule pause <id> and tessl schedule resume <id>), and the API (PATCH /experimental/schedules/:id with only status). Pausing is the reversible way to stop it without a commit, and resuming restores it as it was. A push keeps a paused schedule paused, including one that adds or changes its cron entries.
Everything else is read-only from those surfaces. Attempts to edit or delete it, or a request that changes status along with any other field, return an error telling you it is defined in tessl.json. To change it, change the file and push.
You can still watch a repo-defined schedule and its runs in the web app and CLI.
A repo-defined schedule reflects the last push whose schedules block Tessl applied. While a later push's block is refused, each schedule keeps its previous configuration and keeps running.
Create from the CLI or web app (alternative)
You can also create a schedule directly from the CLI or the web app, without a tessl.json block. This is the older path and is not version-controlled. It may be disabled for some users.
CLI
Use tessl schedule create. Pass the plugin as the first argument, then the repo, the cron, and the environment:
This runs the changelog-writer plugin against my-org/my-repo every Friday at 9:00 UTC.
Common flags:
With --environment, the schedule runs in the project's workspace. Passing --workspace instead of --project uses that workspace's only project; when the workspace has none or several, pass --project. --workspace still chooses the workspace for a schedule created with --from-run.
The cron expression has five fields: minute, hour, day of month, month, and day of week. 0 9 * * 5 means "at 9:00 UTC on every Friday". 0 8 * * * means "at 8:00 UTC every day".
Use --instructions to point a generic plugin at a specific job:
Web app
Open Projects > [project] > Schedules and choose Create schedule. The form asks for the project, schedule (cron), name, description, and environment. These are the primary fields.
Below the environment and mode selection is an Advanced options control. Expand it to set the agent, model, inputs, instructions, and base branch. Leave it collapsed to accept the defaults.
How instructions work
The instructions field layers extra directions on top of whatever the plugin already says. The plugin is the base recipe; instructions are the per-schedule customization. The plugin still runs its normal task. The instructions steer it for this one schedule.
This lets you reuse one plugin for several jobs. The same docs-audit plugin, pointed two ways in one schedules block:
Same plugin, two schedules, two different jobs.
Monitoring your runs
Each schedule keeps a table of its past runs. Open a schedule in the web app to see its full run history, including output and duration. From the CLI, tessl schedule runs <id> lists runs with their ID, status, skill, repo, workspace, creator, and creation time.