Skip to content
RoutineOS
Esc
↑↓navigate↵open⌘Jpreview
On this page

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:

  • schedule must match routine.toml. lint reports a mismatch. The cron is UTC; timezone is only for the permission screen.
  • connectors = ["mail"] names the mail integration. 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 = true is required on a write integration. Without it: integration 'mail' writes externally, set gate = true.
  • what is the purpose the permission screen prints. Without it the screen says “purpose not stated”.
  • tools lists 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_on names registry records this app depends on. platform check reports 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 http integrations 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:

  1. Publish hiring-signals/dashboard/index.html as a private artifact with the capabilities {"db": {}, "user": {}, "downloads": true}. See Desk capabilities.

  2. Seed it:

    python3 $RO seed hiring-signals --out hiring-signals/seed
    ok: wrote 1 batch file(s) to hiring-signals/seed; lint passed. Apply them in order with ArtifactData batch.
  3. Apply seed/batch-001.json to the artifact with one ArtifactData batch call.

  4. 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:

  1. List first. If a task called Hiring Signals exists, decide whether to update it. Never create a duplicate.

  2. 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).

  3. Remove connectors you do not need. All connected connectors are included by default and can write (docs, fetched 2026-10-30). Keep only mail.

  4. 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.

  1. Start a run now with RemoteTrigger run, then fetch the run log and open the Desk.
  2. 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.
  3. Look at state/health. It carries runAt, outcome, itemsHandled, alert and overrides.
  4. If the output is what you wanted, write state/verified. Only your say-so counts. app.toml status becomes “live and tested” once [deployed].routine_ids is 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-init and a failing lint. Use new --app, or add the header from prompt app-header.
  • A misspelled app.toml key. 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.

Was this page helpful?