Build your first routine
Take Hiring Signals from the jobs-watch template to a scheduled task that has run once, report-only. Fill app.toml, check it, publish the Desk, seed it, create the task and verify the first run.
The Quickstart builds Hiring Signals and plans one run’s writes on your machine. This guide carries the same routine the rest of the way: a filled-in app.toml, a clean permission screen, a published Desk, a scheduled task on your claude.ai account, and a first run you have checked.
Every command output on this page is what the CLI printed when the steps were run. Steps that happen on claude.ai (publishing, creating the task) are described, not shown, because they depend on your account.
RO=skills/routineos/scripts/routineos.py
Before you start
- Python 3.11 or later and the plugin installed (Installation).
- A claude.ai plan that includes scheduled tasks. Per the docs these are Pro, Max, Team and Enterprise, and the feature is a research preview (docs, fetched 2026-10-30).
- A GitHub repository for the routine, if you deliver it from a repo (the default).
1. Scaffold with the app layer
python3 $RO new hiring-signals --from-template jobs-watch --app
created hiring-signals
Use new --app, not new followed by app-init. --app also writes the fixed prompt header into prompt.md. app-init adds app.toml, risks.md and CHANGELOG.md but not the header, and lint then fails:
FAIL prompt.md: prompt must mention state/verified
FAIL prompt.md: prompt must mention state/budget
If you already have a folder, print the header with python3 $RO prompt app-header and put it at the top of prompt.md.
2. Fill in app.toml
new --app writes an app.toml with the required keys set and every optional key commented out. For Hiring Signals, set these:
[app]
id = "hiring-signals"
title = "Hiring Signals"
purpose = "Watch public job boards for commerce roles and flag strong matches."
owner = "Your name"
created = "2026-10-30"
status = "not live"
[[routine]]
name = "hiring-signals"
folder = "."
type = "watch"
schedule = "0 22 * * 1,4"
timezone = "Australia/Melbourne"
delivery = "repo"
repo = "your-org/hiring-signals"
connectors = ["mail"]
tools = ["WebSearch", "WebFetch", "Bash"]
[store]
kind = "artifact-db"
[budget]
max_runs_per_day = 1
alert_channel = "email"
[[integration]]
name = "mail"
kind = "connector"
direction = "write"
hosts = []
gate = true
what = "sends gated intro notes to the approver"
[environment]
name = "hiring-signals"
network = "trusted"
allowed_domains = []
setup_script = false
env_vars = []
[risks]
file = "risks.md"
relies_on = ["run-writes-artifact-db", "approval-email-slack"]
What each choice means:
schedulemust matchroutine.toml.lintreports a mismatch. The cron is UTC;timezoneis only for the permission screen.connectors = ["mail"]names themailintegration. The jobs-watch pack declares mail as optional and needed only for gated messages. Declare it now so the permission screen shows it; the routine sends nothing until it is switched live.gate = trueis required on a write integration. Without it:integration 'mail' writes externally, set gate = true.whatis the purpose the permission screen prints. Without it the screen says “purpose not stated”.toolslists the tools a run may use. The fixed prompt header forbids any send tool that is not listed.[environment]records what you read in the claude.ai environment dialog. RoutineOS cannot read those settings, so it shows them as reported by you. List variable names only, never values.relies_onnames registry records this app depends on.platform checkreports their status.
purpose, owner and created are fields of the scaffold. lint does not reject keys it does not know, so a misspelled key (for example purpose on an integration instead of what) is silently ignored. Read the permission screen to catch that.
3. Check it
python3 $RO lint hiring-signals
ok: no problems
python3 $RO platform check hiring-signals
run-writes-artifact-db pilot-verified
approval-email-slack pilot-verified
python3 $RO doctor hiring-signals
Python 3.13.15
tomllib: ok
registry: ok (47 records)
delivery: hiring-signals: repo your-org/hiring-signals
ok: no problems
Your Python version line will differ.
4. Read the permission screen
python3 $RO permissions hiring-signals
The parts that changed with the filled app.toml:
Connectors
- mail: write; sends gated intro notes to the approver
Tools the run may use
- hiring-signals: WebSearch, WebFetch, Bash
- A run may not use a send tool (push, email, Slack or any other) that this screen does not list.
Logic delivered by
- hiring-signals: repo your-org/hiring-signals
Schedule
- hiring-signals: UTC cron 0 22 * * 1,4 (the cron is in UTC; your time zone is Australia/Melbourne), 1 run a day
Environment and secrets
- Environment: hiring-signals
- The settings below are reported by you, not readable by RoutineOS.
- Network access: trusted
Budget
- Ceiling: 1 run a day (prompt-enforced, not guaranteed).
- When the ceiling is passed, further runs are meant to stop and the alert channel (email) is told.
Not yet checked
- Hosts: none are declared, so the hosts this app contacts are not verified.
Read every line. Two to act on:
- Hosts: none are declared. The routine searches and fetches job boards, and those hosts are not listed. If your environment’s network level blocks a board, the run cannot reach it. Declare the hosts you know as
httpintegrations and probe them in the first run. - The budget is prompt-enforced. The cap holds only as long as the run follows its prompt. See Scheduling.
Nothing is created until you say yes to this screen. Any change makes a new one.
5. Publish the Desk and seed it
Push the folder to the repo named in app.toml. Then, in a Claude session:
-
Publish
hiring-signals/dashboard/index.htmlas a private artifact with the capabilities{"db": {}, "user": {}, "downloads": true}. See Desk capabilities. -
Seed it:
python3 $RO seed hiring-signals --out hiring-signals/seedok: wrote 1 batch file(s) to hiring-signals/seed; lint passed. Apply them in order with ArtifactData batch. -
Apply
seed/batch-001.jsonto the artifact with one ArtifactData batch call. -
Put the artifact’s URL in the routine’s prompt. A run that is not told its store looks for one by listing artifacts (
run-finds-store-by-listing, pilot-verified), and the fixed header says “Never find a store by listing artifacts”.
6. Create the scheduled task
Create the task from your own interactive session with /schedule or the RemoteTrigger tool. Neither is available inside a run. See Scheduling for the full procedure. In short:
-
List first. If a task called Hiring Signals exists, decide whether to update it. Never create a duplicate.
-
Create with the approved cron, the repo, the prompt, the environment and the connector list. Give the repo as
session_context.sources: [{"git_repository": {"url": ...}}]; the shape{"type": "git_repository", "url": ...}is dropped without an error (remotetrigger-body-shape, pilot-verified). -
Remove connectors you do not need. All connected connectors are included by default and can write (docs, fetched 2026-10-30). Keep only mail.
-
Read back. List again and confirm the id, schedule, repo, environment and connectors match the permission screen. Put the id in
app.toml:[deployed] routine_ids = ["<the task id>"] environment = "hiring-signals"
7. Run it once, report-only
Every run is report-only until the document state/verified exists. The prompt header reads that flag, so a scheduled fire cannot act early.
- Start a run now with
RemoteTriggerrun, then fetch the run log and open the Desk. - Check what happened: items found, written and dropped, and why. The Today page shows the run; Browse shows the records, including those dropped by settings.
- Look at
state/health. It carriesrunAt,outcome,itemsHandled,alertandoverrides. - If the output is what you wanted, write
state/verified. Only your say-so counts.app.tomlstatus becomes “live and tested” once[deployed].routine_idsis set and the app is verified.
Status rules from appmodel.py:
| Status | When |
|---|---|
not live |
No [deployed].routine_ids |
live, not yet tested |
Ids set, not verified |
live and tested |
Ids set, verified |
retired |
Set by hand |
lint refuses live, not yet tested or live and tested with no routine ids.
8. Before you switch gated messages on
The jobs-watch gate is transport = "email", timeout_hours = 48. The schedule runs Monday and Thursday, so the gaps between runs are 72 and 96 hours. A gate is only read on the next run, and operate.md says an item past its window “is expired and never acted on late”. As shipped, every gate expires before the next run can see the reply. Before going live, raise timeout_hours above 96 or schedule the runs closer together. See Notification channels.
Moving from report-only to sending is its own step with its own permission screen.
Common pitfalls
app-initand a failing lint. Usenew --app, or add the header fromprompt app-header.- A misspelled
app.tomlkey. It is ignored without a warning. Check the permission screen. - Leaving every connector on the task. The default includes all of them, with write access.
- Not naming the store. The run lists artifacts to find one.
- A Desk seeded before it was published. Seed batches go to the artifact’s database, so publish first.