Use when a Skyrim task must turn many load-order records into one catalogue, audit, graph, report, or batch patch.
Scanned 9/3/2026
Install to Claude Code
npx -y skills add ShugokiFable/Ultimate-AI-Starter-Bundle --skill bulk-record-jobs --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Bulk Record Jobs?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/shugokifable-bulk-record-jobs-ff16fa0e)More formats (shields.io, HTML) on the badges page.
---
name: bulk-record-jobs
description: Use when a Skyrim task must turn many load-order records into one catalogue, audit, graph, report, or batch patch.
---
# Bulk Record Jobs
## Overview
A bulk record job is any task shaped "many records in, one structured deliverable out": a gear
catalogue, a crafting-graph, a conflict survey, a whole-mod audit, a compatibility-patch rebuild,
a fan-out extraction returning JSON. The houseCARL tool surface has one primitive for every loop
these jobs used to improvise — this skill maps job shapes to those primitives, carries the few
game-level conventions the binary format doesn't (what a crafting-station keyword means, how a
temper recipe differs structurally from a craft recipe), and defines the one canonical deliverable
shape so ten agents extracting in parallel produce one schema, not ten.
Two costs dominate badly-planned bulk jobs, and both are locked in by the first call: per-record
loops (a reverse lookup run 548 times instead of once; ~400 one-element list ops instead of ~40
batched ones) and schema drift (each extractor inventing its own output encoding). Plan the calls
and fix the output shape before touching a record.
## First step — size the job, then fix the shape
Before the first detail read:
1. **Size the scope with a count, not a dump.** `group_by=` on `housecarl_cross_plugin_query`
aggregates ALL matches into a count table in one cheap call:
```
housecarl_cross_plugin_query(type="ARMO", plugins=["SomeMod.esp"], group_by="defined_in")
```
`group_by="winner"` opens a conflict survey ("who wins the contested records, by plugin");
`"type"` profiles a plugin's contents; `"defined_in"` splits definitions from overrides.
The counts tell you whether the job is 40 records or 4,000 before you commit to a per-row plan.
2. **Write the deliverable schema down** (see "The canonical deliverable shape" below) before any
extraction starts — especially before handing work to subagents. The schema is the contract;
extraction fills it.
## Scope truth — two flags that decide what your numbers mean
These two are the proven silent-wrong traps of bulk work. Neither errors when missed; both
corrupt the deliverable.
- **`defined_in=true` — definitions, not touches.** `plugins=["X.esp"]` alone matches every
record X.esp *touches* — its own definitions AND its overrides of other plugins' records. A
catalogue scope ("the items this mod adds") means *defined in*. A replacer or patch plugin can
return dozens of matches on a bare `plugins=` scope, every one an override that double-counts
into your catalogue under the wrong mod. Pass `defined_in=true` whenever the question is "what
does this plugin add"; leave it off only when overrides are genuinely part of the question.
- **`winner_fields=true` — live values, not the scoped plugin's era.** Under a `plugins=` scope,
`fields=` renders the *scoped plugin's own* version of each value — the defining esp's original
armor rating, not the number the game uses after later overrides. The output says so in a note,
but a bulk consumer that never reads notes ships plausible stats from the wrong era. For any
deliverable claiming live stats, pass `winner_fields=true`; the scoped-values default exists for
the other question ("what did THIS plugin set").
## The loop-killer map
Reach for the primitive, never the loop. Each row below was a real improvisation in a real fleet
run before the primitive existed:
| About to… | Use instead |
|---|---|
| Run `references=` once per target record | ONE call — `references=` takes a LIST (OR semantics; each match reports which target(s) it hit via `matches=`) |
| Post-filter FormIDs by `:Plugin.esp` suffix | `defined_in=true` on the query |
| Dump all matches and tally winners by hand | `group_by="winner"` (counts ALL matches, not capped by `limit=`) |
| Read records one call each | `housecarl_batch_record_detail` with the whole FormID list |
| Read full records just to label FormIDs with names | `housecarl_resolve` — one identity line per FormID (type, editorid, name, winner), per-item error isolation |
| Maintain a FormID→name cache by hand | `resolve_names=true` on the read — every FormLink annotated inline, cached server-side across the batch |
| Parse `path = token` text back into JSON | `format="json"` on the read/query — same tokens, stable document, accounting in-band |
| Read two plugin versions and subtract by hand | `housecarl_diff_record(formid, plugin_a, plugin_b)` — either side may be an on-disk, not-enabled plugin |
| Re-type another version's field values into ops | `verb:"CopyFrom"` with `from_plugin=` in `housecarl_bulk_apply` — transplants the field, source may be off-order |
| Add list elements one op at a time | `composes=[...]` — one op appends N elements (`verb:"Add"`) or replaces the whole list (`verb:"ReplaceAll"`; `composes=[]` clears it) |
## Recipe: the catalogue
"Enumerate everything of type T that mod set S adds, with live stats and resolved names."
1. **Size**: `group_by="defined_in"` over the scope (First step).
2. **Enumerate + flat fields in one pass** (scalar fields only — Name, ArmorRating, weights…):
```
housecarl_cross_plugin_query(
type="ARMO", plugins=["ModA.esp", "ModB.esp"], defined_in=true,
fields=["Name", "ArmorRating"], winner_fields=true, format="json")
```
3. **Expand list-bearing rows in a second, batched pass.** `housecarl_cross_plugin_query` has no
`depth=` — a list field renders as a count note (`[list: 5 item(s)]`). Feed the enumerated
FormIDs to `housecarl_batch_record_detail` with `depth=2`, `resolve_names=true`,
`format="json"`: each list element comes back indexed (`Keywords[0]`…) with a structured
`link` sibling (`{resolved, type, editorid, name}`) beside the raw token.
4. **Respect the accounting.** Every JSON document carries `total` / `rendered` / `capped` /
`truncated` / `notes` in-band. `capped=true` means raise `limit=` or page by narrowing scope —
never ship a deliverable whose `total` exceeds its row count without saying so.
## Recipe: the link graph
"For each record, what points at it / what does it point at, as names not hex." Works for any
link-shaped question — crafting recipes, outfits, leveled lists, dialogue — because the reflection
layer knows every FormLink on every type. One graph level = two batched calls:
1. **Reverse edge** (who points at these?): `references=[...]` with the whole target list, bounded
by `type=` — e.g. every recipe involving a list of items is ONE call with `type="COBJ"`.
2. **Contents + identity**: `housecarl_batch_record_detail` on the matches, `depth=` deep enough
for the paths you need, `resolve_names=true` so every link is labeled inline.
Worked example — the crafting graph for one weapon (verified live; vanilla data):
```
housecarl_cross_plugin_query(type="COBJ", references=["012EB7:Skyrim.esm"])
→ TemperWeaponIronSword, RecipeWeaponIronSword, … (every recipe touching Iron Sword, one scan)
housecarl_batch_record_detail(
formids=[…those COBJs…], depth=5, resolve_names=true, format="json",
fields=["EditorID", "Items", "WorkbenchKeyword", "CreatedObject", "CreatedObjectCount", "Conditions"])
```
The load-bearing COBJ paths (only visible at depth ≥ 4–5):
- materials: `Items[i].Item.Item` (the ingredient link) + `Items[i].Item.Count`
- station: `WorkbenchKeyword` (a Keyword link — see conventions below)
- product: `CreatedObject` + `CreatedObjectCount`
- gates: `Conditions[i].Data.Function` names the condition kind; `Conditions[i].Data.Perk`
carries the perk link when the function is `HasPerk`
`resolve_names` annotations resolve against the **live load order**: reading a vanilla body on a
modded order can label a link with a mod's renamed identity for that FormID. That's the truthful
in-game identity — but if the deliverable documents the *original* plugin's era, resolve names
from that era's values instead of assuming the annotation matches the source.
## Crafting-station conventions (what the format doesn't say)
The binary format stores `WorkbenchKeyword` as just a Keyword link; which station it is and what
the recipe *means* there is Creation-Kit convention:
- **Enumerate stations from data, don't hardcode**: crafting-station keywords are vanilla KYWD
records — `housecarl_cross_plugin_query(type="KYWD", editorid_contains="Crafting",
plugins=["Skyrim.esm"], defined_in=true)` lists them live (forge, skyforge, sharpening wheel,
armor table, smelter, tanning rack, cookpot…). Ignore the `WICrafting*` ones — those are
radiant-story event keywords, not stations. Mods add their own station keywords; the same query
without the plugin scope finds them.
- **Craft vs temper is structural, not naming.** A *craft* recipe (forge/smelter/tanning/cookpot):
`CreatedObject` = the item produced from the materials. A *temper* recipe (sharpening wheel for
weapons, armor table for armor): `CreatedObject` = the very item being *improved*, count 1, and
the canonical vanilla condition pair is `EPTemperingItemIsEnchanted != 1` (OR-flagged) +
`HasPerk(<arcane-smithing-class perk>)`. Classify by `WorkbenchKeyword` + this shape.
- **Conventions are conventions — the data can be dirty.** Vanilla itself ships
`TemperWeaponSkyforgeBow` whose `CreatedObject` is a *battleaxe*. Classify from the fields you
read, and let an EditorID that disagrees with the structure raise a flag in the deliverable
rather than win the argument.
- **A recipe that appears at no station** (null/odd `WorkbenchKeyword`, or gated by an
impossible condition) is a common *mod-specific* disablement idiom — whose meaning belongs to
that mod's own skill lane, not here. Report the structure; don't guess the intent.
## Recipe: the patch rebuild
"Re-derive an old compatibility patch against a new version of its target mod." The job is
delta-analysis then batched re-application; the old patch is usually *disabled*, which is fine —
the diff and copy primitives read on-disk plugins that aren't in the load order.
1. **Survey the contest**: `group_by="winner"` over the target mod's records — one call says how
much of the mod is contested and by whom.
2. **Extract the old delta per record**: `housecarl_diff_record(formid, plugin_a="OldPatch.esp",
plugin_b="TargetMod.esp")` — field-level deltas, each labeled by plugin_b's name; `fields=`
scopes the comparison; a truncated read reports itself rather than claiming "identical".
3. **Forward the new baseline** (`housecarl_forward_record`), then **re-apply the still-valid
deltas** with `housecarl_bulk_apply into=` on the forwarded copies — ops target the patch's
own already-forwarded body, not the stale winner (the forward-then-edit precedence both tools'
docs state).
4. **Batch the re-application**: whole-field transplants use `verb:"CopyFrom"` +
`from_plugin="OldPatch.esp"` (works from a disabled plugin on disk); list rebuilds use
`composes=[...]` — N perk placements in one op, `ReplaceAll` + `composes` to rebuild a modeled
list wholesale, `ReplaceAll` + `composes=[]` to empty it. All-or-nothing per call: one bad
element refuses the whole call with per-element reasons.
5. **Verify before enabling**: `housecarl_check_errors` and `housecarl_compact_plugin` accept the
not-yet-enabled patch; `housecarl_diff_record` with the new patch as a pole confirms the
deltas landed.
## Recipe: the fan-out
When the job fans out to subagents, schema drift is the failure mode — N agents told "extract X,
return JSON" will invent N schemas unless the orchestrator pins one. In every subagent prompt:
- **Hand the agent the deliverable schema** (below), filled with the job's field list — not a
prose description of it. Agents hardwire against what you show them.
- Name the exact calls: the scope flags (`defined_in`, `winner_fields`), `format="json"`,
`resolve_names=true`, and the field paths with their depths. A recipe the orchestrator verified
once beats eight agents re-deriving it.
- Require the accounting fields in each agent's return, so a truncated extraction surfaces at
merge time instead of shipping silently incomplete.
## The canonical deliverable shape
One blessed shape for "many records → one document". Use it as the return contract for subagents
and the final deliverable alike; add job-specific keys rather than restructuring.
```json
{
"job": "one line: what this deliverable is",
"scope": {"type": "WEAP", "plugins": ["ModA.esp"], "defined_in": true, "winner_fields": true},
"total": 548,
"complete": true,
"notes": ["tool notes + job caveats carried here, never dropped"],
"records": [
{
"formid": "012EB7:Skyrim.esm",
"type": "Weapon",
"editorid": "IronSword",
"name": "Iron Sword",
"winner": "SomePlugin.esp",
"fields": {"Damage": "7"}
}
]
}
```
The rules that make it a contract:
- **`formid` is the full wire token** (`XXXXXX:Plugin.esp`), verbatim from tool output — never
bare hex (collides across plugins), never reformatted (a token read is a token a write can reuse).
- **Identity is four keys**: `type`, `editorid`, `name`, `winner` — straight from
`housecarl_resolve` / the read header. `name` is `null` where the type has none; don't substitute
the editorid.
- **A resolved link is an object, not a replacement**: `{"formid": "…", "editorid": "…",
"name": "…"}` — keep the token AND the identity; a name-only column can't be queried again.
- **Field values are wire tokens verbatim.** Reformatting (unit conversion, rounding, splitting)
happens in a separate presentation layer, never in the extraction rows.
- **Accounting is mandatory**: `total` (true match count), `complete` (every row present?), and
`notes` travel with the document. A partial deliverable that says so is fine; one that doesn't
is a silent wrong answer.
- **Closed enums for classifications**, declared in the deliverable (e.g. a recipe `kind` of
`craft` / `temper` / `other`) — free-text classification is where eight agents drift eight ways.
For crafting-graph jobs specifically, the blessed per-recipe entry:
```json
{
"recipe": "0DA769:Skyrim.esm",
"kind": "craft",
"station": "CraftingSmithingForge",
"creates": {"formid": "012EB7:Skyrim.esm", "editorid": "IronSword", "name": "Iron Sword", "count": 1},
"materials": [{"formid": "05ACE4:Skyrim.esm", "editorid": "IngotIron", "name": "Iron Ingot", "count": 2}],
"requires_perks": [{"formid": "…", "editorid": "…", "name": "…"}],
"other_conditions": 1
}
```
## Common mistakes
- **Looping a primitive that takes a list.** `references=`, `housecarl_resolve`,
`housecarl_batch_record_detail`, `composes=` — all batch. The single-item call in a loop is
the old world.
- **Shipping scoped values as live stats.** The `plugins=` + `fields=` default renders that
plugin's own era; a catalogue built without `winner_fields=true` is plausibly-wrong everywhere
a later plugin rebalanced.
- **Counting overrides as content.** A bare `plugins=` scope on a patch/replacer returns
overrides; without `defined_in=true` they double-count into the catalogue under the wrong mod.
- **Parsing text output when `format="json"` exists.** Hand-parsing is where schema variance
creeps in; the JSON document carries the same tokens plus the accounting.
- **Dropping the accounting.** `capped`, `truncated`, `rendered < total`, and `notes` exist to be
propagated. A deliverable that silently ignores them claims completeness it doesn't have.
- **Trusting names over structure.** EditorIDs and display names are conventions; classify from
fields (station keyword, `CreatedObject` relationship, condition functions) and flag
disagreements — vanilla itself contains miswired records.
- **Re-typing what `CopyFrom` can transplant.** Reading a value out of one plugin and composing it
back by hand re-introduces transcription risk the primitive exists to remove.
## Sub-topic routing
| The question is really… | Go to |
|---|---|
| "What fields does record type X have / what are the legal enum values?" | `mutagen-reference` |
| "Which armors sit on biped slot N?" | `biped-slot-reference` |
| What a specific mod's keywords/conventions *mean* (any per-mod semantics) | that mod's own skill lane (e.g. a Requiem companion skill), never encoded here |
| One record to read or edit | the tools directly — no bulk planning needed |
| Distributing spells/keywords/items at runtime instead of cataloguing them | `spid-authoring` / `kid-authoring` / `skypatcher-authoring` |
## Notes
- `housecarl_cross_plugin_query` has no `depth=` — list expansion always goes through
`housecarl_batch_record_detail` (or `housecarl_read_record` for one record). Budget the second
call into any list-bearing catalogue plan.
- `where=` accepts FormLink equality against a wire token (e.g.
`"WorkbenchKeyword = 088108:Skyrim.esm"`) — a station filter is one predicate, no post-filtering.
- Off-order (disabled, on-disk) plugins are first-class poles for `housecarl_diff_record`,
sources for `CopyFrom`, and targets for `housecarl_read_plugin_file` / `housecarl_check_errors`
/ `housecarl_compact_plugin` — "the old patch is disabled" blocks nothing.
- Aggregation (`group_by=`) counts ALL matches regardless of `limit=`; only row *rendering* is
capped. Use counts freely for sizing.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!