Hybrid routines
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.
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.
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 instead when the input is already structured and there is nothing to read. Add a 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
textfield (such astitle) matches a case-insensitive substring, so the keywordcommercematches “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:
- The model searches each market for postings inside the lookback window whose title matches a keyword.
- It records a
role(title, company, company_domain, market, location, remote, salary_max, posted, url, seniority, summary) linked to acompany(name, domain, industry, size, country), for every posting it read.summaryis 1 to 2 factual sentences, at most 400 characters, with no inference. - It writes Inbox messages only for roles a person should look at today:
match,alertorproposal. - An intro note becomes an Inbox message of kind
approvalwith a gate. Nothing is sent. - It builds the run input per
references/write-contract.md, with statuspartialif a market failed to load. learnwriteslearning.jsonandproposals.json; the model puts the proposals at the front ofinbox.writeapplies 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.tomlthat nosettingFiltersentry 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 writestowhen a person accepts. - Expecting
new --from-templateto copy scripts or skills. It copiesroutine.toml,prompt.md,README.mdanddefinition.json. That is enough forjobs-watch, not for templates withlib/orskills/.