---
title: Build your first routine
description: 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.
sidebar:
  order: 1
---

The [Quickstart](/introduction/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](/introduction/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](https://code.claude.com/docs/en/web-scheduled-tasks), 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:

```toml
[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](/infrastructure/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](/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](/infrastructure/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](https://code.claude.com/docs/en/web-scheduled-tasks), 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`:

   ```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](/infrastructure/notification-channels#worked-example).

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.

## Related

- [Quickstart](/introduction/quickstart)
- [Scheduling and Claude cloud routines](/infrastructure/scheduling)
- [routine.toml and app.toml reference](/reference/routine-toml)
- [CLI reference](/reference/cli)
