Learning
How a routine improves from what people do. Signals, thresholds, rejection memory, levels, and the learn then append then write order.
What it is
Learning is the step where a routine looks at what people did with its last runs, counts the gap between what it did and what they did, and, when the gap is big and steady enough, puts one plain suggestion in the Inbox. A person accepts, rejects or snoozes it. A routine never applies its own suggestion.
Why it exists
People correct routines all the time, and most of those corrections are thrown away. A person who keeps five roles the settings dropped is saying the settings are wrong. Learning turns that into a specific change (“Add hydrogen to title_keywords”) with the evidence attached, so the routine gets better from use, not from someone rewriting the prompt.
Code does the counting and the deciding. The model only phrases. That way thresholds, cooldowns and rejection memory hold on every run.
How it works
Turning it on
The jobs-watch template ships with learning on:
"learning": {"enabled": true}
Every other key is optional. With all defaults written out:
"learning": {"enabled": true, "signals": {"dead_item": false}, "limits": ["max_inbox_per_run"],
"minSample": 5, "minEffect": 0.6, "cooldownDays": 14, "maxPerRun": 3, "deadAfterRuns": 10}
lint refuses unknown keys, unknown signals and out-of-range numbers. Every signal is on unless turned off. limits is empty unless you declare it; limit_pressure only counts limits named there.
Signals
| Signal | The gap it counts | Status |
|---|---|---|
override |
A person kept a record a setting dropped (exclude: the value that dropped it; include: the value it lacked) | counted |
uncaptured_term |
Kept records an include text filter missed share a plain word | counted |
dead_item |
An item of a list setting matched nothing for deadAfterRuns runs of its type |
counted |
limit_pressure |
Runs offered more Inbox messages than a declared limit allowed | counted |
source_health |
A source keeps failing or returning nothing | named, not yet counted |
connections |
People link records the routine did not | named, not yet counted |
edit_drift |
People keep editing the same field the same way | named, not yet counted |
manual_repetition |
People repeat the same action by hand after runs | named, not yet counted |
Details the code holds:
- Drops are counted once per record value, so a record seen in several runs counts once.
uncaptured_termproposes words from include text fields, skipping stopwords and words under 3 characters.- On an exclude list,
overrideproposes removing the value that dropped kept records. dead_itemis skipped when every item of an include list is dead (that points at a broken source, not bad items).limit_pressureproposes the cap plus the largest overflow seen, never above the setting’smax. It counts onlymatchandalertmessages the contract would keep.
Since 0.5.0: a ninth signal,
people_drop. When people drop records the routine kept, and those records share a word in a text field bound to an exclude list, learn proposes adding that word to the exclude list, with the same thresholds and memory. The jobs-watch template gainsexcluded_title_words(list, exclude, default empty, example “intern”) bound to the roletitle.
Deciding
A candidate is raised only when all of these hold:
- at least
minSampledistinct kept records (or pressured runs), and their share is at leastminEffect; - its key was not rejected or dismissed within
cooldownDays, is not snoozed, and is not waiting undecided on an earlier run’s card; - it passes the write contract’s proposal check.
At most maxPerRun are raised per run, strongest first. Rerunning learn for the same run id does not count twice. An include list is never proposed empty.
Levels
| Level | Changes | Accept does |
|---|---|---|
| L1 | One setting item: add, remove or set one value |
Applies the item to the setting as it is now and records the decision |
| L2 | The definition (a field, a type, a binding); target definition |
Records the decision only; the card says it opens a PR |
| L3 | The workflow or prompt; target workflow or prompt |
As L2 |
Opening the PR for L2 and L3 is a follow-up. Today the card records the decision only.
The run order
Each run has one order: learn, then append, then write.
python3 skills/routineos/scripts/routineos.py learn --docs snapshot.json --run run.json --out learn
learn writes learning.json (one batch setting state/learning, phase learning) and proposals.json (messages). Apply learning.json, put the proposals.json messages at the front of the run’s inbox unchanged (the model may reword title and why only), then run write. A run already marked done writes nothing more, so proposals appended after write are lost.
Guardrails
- Never self-apply. Only a person’s Accept changes a setting.
- Learned-only fields.
writerecomputes this run’s learn output and refuses any per-item proposal (one withop,value,key,level,target,change,signalorevidence) that the learn step did not raise. The model cannot invent one. - Rejection memory. Reject (and Inbox Dismiss) blocks the same key for
cooldownDays. Snooze blocks it until itsuntil(7 days on the Desk); a snooze withoutuntilblocks for good. Asetis keyed by setting alone, so rejecting “set to 8” also blocks “set to 9”. - Never learnable. Any setting whose name, with case and separators removed, contains
push,gate,send,alert,permission,budget,maxrunsorcapoverrideis refused in every proposal shape. - Values only. A proposed value must match
^[A-Za-z0-9][A-Za-z0-9 .&+#/'_-]{0,59}$, so instruction-like text from a fetched page cannot travel through a proposal.
The off switch
Setting values.learning: false in settings/workspace turns learning off for the whole Desk. Tallies still count, nothing is proposed, and write refuses every proposal message, including model-written ones.
Worked example
In one Hiring Signals run, six roles titled “Hydrogen Developer”, “Hydrogen Engineer” and similar were dropped with not in title_keywords. On the Desk, a person kept five of them. On the next run:
python3 $RO learn --docs snap2.json --run run2.json --out l2
ok: 1 proposal(s) in proposals.json; apply learning.json as one ArtifactData batch
l2/proposals.json:
[{
"kind": "proposal",
"title": "Add hydrogen to title_keywords",
"why": "People kept 5 of 6 records dropped for not matching title_keywords; they carry hydrogen.",
"proposal": {
"setting": "title_keywords", "op": "add", "value": "hydrogen", "signal": "uncaptured_term",
"evidence": {"count": 5, "of": 6, "refs": ["role--https-jobs-example-au-200", "...", "role--https-jobs-example-au-204"]}
}
}]
After the message is appended and write runs, the stored Inbox message carries the computed change:
"proposal": {
"setting": "title_keywords", "op": "add", "value": "hydrogen",
"from": ["Shopify", "headless", "commerce", "AI automation"],
"to": ["Shopify", "headless", "commerce", "AI automation", "hydrogen"],
"key": "title_keywords:add:hydrogen", "level": "L1", "signal": "uncaptured_term",
"evidence": {"count": 5, "of": 6, "refs": ["..."]}
}
The person accepts it on Suggestions. The Desk adds hydrogen to settings/hiring-signals and records the decision. From the next run, Hydrogen roles are active.
If the model had instead written its own per-item proposal, write would refuse with exit code 2:
error: proposal message 'x' is a per-item proposal but is not one this run's learn step raised; append proposals.json from routineos.py learn unchanged
Reference
| Key | Default | Range |
|---|---|---|
enabled |
none (required) | bool |
signals |
all on | {signal: bool} |
limits |
[] |
known limits: max_inbox_per_run |
minSample |
5 | at least 1 |
minEffect |
0.6 | a share above 0 and at most 1 |
cooldownDays |
14 | at least 0 |
maxPerRun |
3 | 0 to 10 |
deadAfterRuns |
10 | at least 2 |
Learn needs an ISO startedAt or endedAt on the run input.
Common pitfalls
- Expecting a suggestion after one run.
minSampleis 5. Learning needs people to have made enough decisions. - Writing proposals by hand in the prompt. Per-item proposals are refused. A plain
{setting, from, to}proposal is still allowed and still checked. limit_pressurenever fires. Declare the limit:"limits": ["max_inbox_per_run"].- Turning learning off per routine to silence one suggestion. Reject it instead; rejection memory holds it back for
cooldownDays.