---
title: Hybrid routines
description: The default kind. The model reads and judges; code applies every rule a person can edit, computes ids and writes the store. Where the line sits, how settings filters work, and Hiring Signals (jobs-watch) in full.
sidebar:
  order: 5
---

## What it is

A hybrid routine splits the work. The model does what needs reading: search, fit, summaries, drafts. Code does what must give the same answer every run: settings filters, caps, ids, outcomes, learning and the store write. Most useful routines are hybrids. The piloted example is Hiring Signals, built from the `jobs-watch` template.

Like the other kinds, "hybrid" is a design pattern, not a field. In a pack it shows as a mix of `model` and `code` stages; in a prompt it shows as reading steps followed by `routineos.py learn` and `routineos.py write`. See [Routine kinds](/routine-kinds).

## Why it exists

Neither half works alone. A pure prompt cannot guarantee that a Desk edit to `title_keywords` changes the next run: the model may read the new value and still apply its own idea of a match. Pure code cannot tell a real commerce rebuild from a support role. A hybrid gets both, with a clear line between them.

The line is one rule: **a rule a person edits in Settings is always code.** If the model applied it, an edit on the Desk would not reliably change the run.

### When to choose it

Choose a hybrid when:

- The routine reads unstructured sources (web pages, mail, documents) and a person tunes what it keeps.
- Results need stable ids across runs, so a role seen on Monday and Thursday is one record with two sightings.
- The routine should learn from feedback and propose settings changes.

That covers almost every watch routine. Choose [code](/routine-kinds/code) instead when the input is already structured and there is nothing to read. Add a [skill](/routine-kinds/skill) when one of the model's steps needs long, shared guidance.

### Use cases

| Use case | Model half | Code half | Shipped example |
|---|---|---|---|
| Watch job boards for hiring signals | Search, read postings, summarise | Market, keyword and exclusion filters, ids, learning | `jobs-watch` (piloted) |
| Triage an inbox | Label, summarise, draft | Domain skips, caps, the write | `inbox-triage` (unpiloted) |
| Weekly marketing report | Collect numbers, audit pages | Week-over-week change | `weekly-marketing-report` (unpiloted) |
| Shortlist candidates | Mark each against the rubric | Score totals | `candidate-shortlist` (unpiloted) |

## How it works

### Who does what

For Hiring Signals:

| Step | Who | Why |
|---|---|---|
| Read settings | Model reads `settings/<routine>` | Values only; it never writes `settings/` |
| Search and read postings | Model | Needs reading |
| Record roles and companies | Model, into `run.json` | Structured output |
| Apply `markets`, `title_keywords`, `excluded_companies`, `excluded_title_words` | Code (`write`) | A rule people edit is code |
| Ids, outcomes, counts | Code (`write`) | Stable across runs |
| Count signals and propose | Code (`learn`) | Thresholds and memory hold |
| Phrase Inbox messages | Model | Wording |
| Hold the intro note | Gate | Fixed mechanism |
| Write the store | Code (`write`), applied as ArtifactData batches | One contract |

### Settings filters

A setting becomes a filter in `definition.json`. Each type lists `settingFilters`, pairing a setting with the record field it tests. From `templates/jobs-watch/definition.json`:

| Type | Setting | Field | Role |
|---|---|---|---|
| `company` | `excluded_companies` | `domain` | exclude |
| `role` | `markets` | `market` | include |
| `role` | `title_keywords` | `title` | include |
| `role` | `excluded_title_words` | `title` | exclude |
| `role` | `excluded_companies` | `company_domain` | exclude |

The role (include or exclude) comes from the setting's `role` in `routine.toml`. `write` applies them in `scripts/desk_contract.py`:

- An **include** setting with values drops a record whose field matches none of them: `not in title_keywords`.
- An **exclude** setting drops a record whose field matches any of them: `excluded by excluded_companies`.
- An empty setting, or an empty field, filters nothing.
- A `text` field (such as `title`) matches a case-insensitive substring, so the keyword `commerce` matches "Ecommerce Developer". Other field types (`choice`, `url`) match whole values, case-insensitive for text. The substring rule came from a pilot run, 2026-10-29-0438, in which whole-word matching dropped two Ecommerce roles (`references/write-contract.md`).

A dropped record is still written, with status `dropped` and the reason. A person who marks it "keep" on the Desk overrides the filter while that decision stands, and a person's "drop" wins over the filters the same way.

### Report everything, let code drop

The model is told to report every posting it read, including ones the settings will drop. From the `jobs-watch` prompt, step 2: "Report every posting you read, including ones the settings will drop; the write contract marks those dropped with a reason." People can then see what the settings cut and keep the ones they want, and `learn` can count how often a setting cuts something a person kept.

### Learn, then append, then write

`learn` turns feedback on earlier runs into `learning.json` and `proposals.json`. The model puts the proposals at the front of the run's `inbox` unchanged (it may reword `title` and `why` only), and then `write` applies the run. The order is fixed: proposals appended after the write are lost. The model never writes a per-item proposal itself; `write` refuses one unless this run's `learn` raised it.

## Worked example

Hiring Signals tracks public job postings for commerce and automation roles, links each role to its company, and puts the few that need a person in the Inbox. It is built from `jobs-watch`, the one piloted template. It never sends, posts or deletes.

### The routine

From `templates/jobs-watch/routine.toml`:

| Field | Value |
|---|---|
| `type` | `watch` |
| `schedule` | `0 22 * * 1,4` (22:00 UTC Monday and Thursday) |
| `first_run` | `report-only` |
| Store | `artifact-db` |
| Gate | email, 48-hour timeout |

Settings, all editable on the Desk:

| Setting | Default | Notes |
|---|---|---|
| `markets` | AU, US, CA, NZ, PH | Include; each market adds at least one search |
| `title_keywords` | Shopify, headless, commerce, AI automation | Include |
| `excluded_companies` | empty | Exclude, by domain |
| `excluded_title_words` | empty | Exclude, by title |
| `lookback_days` | 7 | 1 to 30 |
| `max_inbox_per_run` | 5 | 0 to 20 |

### The stages

From the `jobs-watch` pack, `site/templates/jobs-watch.json`:

| Stage | Kind | Does |
|---|---|---|
| `search` | model | Searches each market for postings inside the lookback window whose title matches a keyword, and opens the ones it reports (`web-search`, `web`) |
| `record` | model | Records each role linked to its company and drafts the Inbox messages, inside `max_inbox_per_run` |
| `learn` | code | `python3 skills/routineos/scripts/routineos.py learn --docs snapshot.json --run run.json --out learn` |
| `write` | code | `python3 skills/routineos/scripts/routineos.py write --docs snapshot.json --run run.json --out batches`; writes runs, entities, inbox and pending |
| `approve` | gate | A person approves or declines an intro note by email; the gate times out after 48 hours. Nothing is sent from this routine (`mail`) |

Connectors: the built-in `web-search` and `web` tools (read, required) and a mail connector (write, optional, alternative Slack) for the gate.

### Build it

```
python3 skills/routineos/scripts/routineos.py pack install site/templates/jobs-watch.json --dir hiring-signals --name hiring-signals
```

`jobs-watch` has no `lib/` or skill files, so `new hiring-signals --from-template jobs-watch` gives the same routine files without `pack.json`.

### The run

The prompt has seven steps:

1. The model searches each market for postings inside the lookback window whose title matches a keyword.
2. It records a `role` (title, company, company_domain, market, location, remote, salary_max, posted, url, seniority, summary) linked to a `company` (name, domain, industry, size, country), for every posting it read. `summary` is 1 to 2 factual sentences, at most 400 characters, with no inference.
3. It writes Inbox messages only for roles a person should look at today: `match`, `alert` or `proposal`.
4. An intro note becomes an Inbox message of kind `approval` with a gate. Nothing is sent.
5. It builds the run input per `references/write-contract.md`, with status `partial` if a market failed to load.
6. `learn` writes `learning.json` and `proposals.json`; the model puts the proposals at the front of `inbox`.
7. `write` applies the filters and writes five batches: started, entities, summary, inbox, done.

### The settings line at work

Suppose a run reads three postings:

| Title | Market | Company domain | Result |
|---|---|---|---|
| Senior Ecommerce Developer | AU | shop.example | Active: "commerce" is a substring of the title |
| Shopify Support Agent | US | help.example | Dropped, `excluded by excluded_title_words`, once a person adds "Support" |
| Hydrogen Engineer | NZ | build.example | Dropped, `not in title_keywords`, until a person adds "hydrogen" |

When a person accepts "Add hydrogen to title_keywords" on the Desk, the next run's `write` treats "Hydrogen Engineer" as a match with no prompt change. When a person marks the support role "keep", it stays active despite the filter. Neither change touches the prompt or the model's judgement.

## Common pitfalls

- **Telling the model to skip postings the settings would drop.** Then nobody sees the drops and learning has nothing to count.
- **The model applying a keyword itself.** The model's idea of a match and the code's can differ; in a pilot they disagreed over "Ecommerce" and `commerce`. Let the code decide.
- **A setting with no filter.** A setting in `routine.toml` that no `settingFilters` entry names is only advice to the model. If a person expects it to hold, bind it to a field.
- **Learning after write.** Proposals appended after the done batch are lost. The order is learn, then append, then write.
- **The model writing `settings/`.** It proposes a change with `{"setting", "from", "to"}`; the Desk writes `to` when a person accepts.
- **Expecting `new --from-template` to copy scripts or skills.** It copies `routine.toml`, `prompt.md`, `README.md` and `definition.json`. That is enough for `jobs-watch`, not for templates with `lib/` or `skills/`.

## Related

- [Routine kinds](/routine-kinds)
- [Your first routine](/guides/first-routine)
- [Settings](/concepts/settings)
- [Learning](/concepts/learning)
- [Write contract](/reference/write-contract)
- [Code routines](/routine-kinds/code)
- [Skill routines](/routine-kinds/skill)
