ZZBX / NOTES ← All notes
AutoSAR · Agent Engineering · Python

Designing an engineering-grade
skill for an AI agent

A complete design walkthrough of a DBC communication-matrix audit skill: where to put the boundary between model and code, what shape a skill should take, and how to prove it works when a model drives it. Zero dependencies, 61 tests.

2026-11-04 ◆ 14 min read ◆ Python / stdlib ◆ Open source
61unit tests
11lint checks
14diff codes
0dependencies
Pipeline from one DBC file to 101 findings: a parser that keeps metadata, six deterministic check families, and a graded report
Two real matrices in, 101 findings out — and the two that matter most are the two a text diff cannot name

The boundary: what belongs to the model

An LLM is very good at judgement and very bad at counting. Ask it to explain what a 2-bit signal with no value table probably means and you get a useful answer; ask it to verify that no two signals in a 256-bit frame overlap, across 379 signals, and you get something that looks like an answer.

That is the whole design premise. Engineering work splits into two categories, and mixing them degrades both:

Belongs to the model Belongs to code
Deciding which of several checks matters here Whether two signals occupy the same bit
Translating a finding into the symptom it causes Walking 379 signals and comparing bit ranges
Spotting that a changelog disagrees with the file Extracting a CRC data id out of a comment
Choosing the order to run things in Getting the answer right every single time

A skill is the seam between the two. It has to carry enough structure for the model to know what to do, and enough determinism that the numbers never depend on the model. Getting that seam right is the actual engineering work, and almost everything below is about it.

Design premise

Anything that must be reproducible goes into code. Anything that requires context or judgement stays with the model. A skill that asks the model to do arithmetic is not a skill, it is a hope.

Domain knowledge becomes deterministic code

The first decision was not 'write a parser' but 'which data does the audit actually need'. That question determines the architecture, and the answer ruled out the obvious off-the-shelf option.

cantools is a good library and I use it elsewhere. But it is built for decoding, and it discards exactly the four things an audit needs:

Data the audit needs cantools .messages This tool
Cycle time GenMsgCycleTime✗✓
Value tables VAL_partial✓
Comments CM_✗✓
Attributes BA_✗✓

Losing those is not a detail. Without a value table, a 2-bit signal reads as the number 2 and you cannot tell whether that means 'On' or 'Fault'. Without a cycle time, a frame cannot be checked against a timing budget. In an audit, the metadata is the payload.

So the parser is hand-written: standard library only, every field the format defines preserved.

Encoding the part of the spec everyone gets wrong

DBC looks line-oriented and is not. The detail that matters most for correctness is Motorola bit numbering: 39|8@0+ does not mean 'start at bit 39 and read forward'. The start bit names the most significant bit, and the cursor walks toward the LSB.

BYTE 1 ← byte 1 bit 15 14 13 12 11 10 9 8 SG_ Bus_Off_Count : 15|4@0+ start bit 15 = MSB walk → LSB (bit 12) pos + 15 if pos % 8 == 0 pos - 1 otherwise read it as an LSB index and every signal lands on wrong bits
Encoding the spec's convention correctly is what makes overlap detection trustworthy — a wrong bit index produces a green audit on physically wrong data.

This is where domain experience pays off. It is not knowledge an LLM lacks exactly — it is knowledge you cannot afford to have re-derived, differently, on every run.

Skill shape: writing for the reader that loads it

The tool worked; then came a design question that had nothing to do with code. A skill file is not documentation. Its reader is a model, and the model loads it at a specific moment, under a specific budget.

CONTEXT COST — WHAT LOADS WHEN description always resident user speaks trigger match SKILL.md loaded on trigger refs on demand THE SPLIT "tell the agent what to do" → SKILL.md "explain to a human why" → README / references SHAPE Step 0 · decide mode Step 1 · probe scale Step 2 · fix encoding Step 3 · run Step 4 · report + what the tool cannot see → forces a human cross-check + error → symptom table → nobody cares about the code
Three tiers, three readers, three budgets. Mixing them is what makes a skill feel like a manual nobody finishes.

The core is Step 0 — mode detection: a table mapping the user's wording to a subcommand, with an explicit instruction not to ask a clarifying question. It is the highest-leverage paragraph in the file, because it removes a round-trip on every single use.

# user wording                              -> subcommand
# ─────────────────────────────────────────────────────────────────────
"audit / check / anything wrong?"          -> lint
"compare / what changed / two revisions"   -> diff
"decode these bytes / what is this value"  -> decode
"garbled / GBK / question marks"           -> fix-encoding

# both "check" and "compare" -> run diff FIRST
# a change to something shipped outranks a flaw in something not yet shipped

The second design move is the section most skills omit: what the tool cannot see. A node declared in the header that transmits nothing. A changelog promising frames the file does not contain. An attribute filled with one value across the board. A linter can never report these — and if the file does not say so, a clean run reads as 'the matrix is fine'.

Design principle

A skill's value is not only what it automates, but how precisely it tells the model where automation stops. That boundary is the difference between a tool and a crutch.

Capability design: what a naive diff cannot express

The second capability is revision comparison. The obvious implementation keys frames by CAN id and walks both maps — and it works, in the sense that it produces output. The design question is whether that output can express the changes that actually break a vehicle network.

NAIVE · key by frame id 0x457 NM_Autosar_FLM_R − message removed 0x46B NM_Autosar_FLM_R + message added cause buried in 101 findings NAME-FIRST · pair across ids NM_Autosar_FLM_R DIF-ID 0x457 → 0x46B add / remove pair suppressed 1 finding, cause first WHY IT MATTERS routing tables and acceptance filters still point at 0x457 → the frame is received by nobody; network management stops working
One removal plus one addition reads like routine churn. The defect is that the address moved.

So the comparison runs two passes: pair by name across ids, emit one DIF-ID, and suppress the misleading add/remove pair. The same reasoning produces signal-rename detection keyed on bit geometry — because when a name changes, bit geometry is the only thing left to anchor on.

The finding that lives inside prose

A checksum's data id is not a structured field. It sits inside the signal comment as free text:

CM_ SG_ 466 VIU_Info_Checksum "CRC16 checksum (DataID: 0x61D2)";

A structural comparison sees a comment that changed and files it as info. But a receiver validates the CRC with that id: change it and every frame is rejected. The symptom is not an error, it is silence — the module simply never responds, and the sender gets investigated for a day. So the tool extracts the id with a regex and promotes a change to error.

RE_DATAID = re.compile(r"data\s*id\s*[::]?\s*(0x[0-9A-Fa-f]+)", re.I)

# DataID present on both sides and different -> not DIF-CMT, raise DIF-CRC (error)
if old_id and new_id and old_id != new_id:
    return DiffFinding("DIF-CRC", "error", ...)
The transferable insight

Both cases are the same insight: some of the most dangerous information in an engineering artefact is not in its schema. It is in a comment, a string, a naming convention. A tool that reads only the schema reports success on a broken file.

Quality system: making claims checkable

A tool whose output nobody can verify is not trustworthy, it is just confident. Three mechanisms, each aimed at a different failure:

Failure mode Mechanism
Code produces wrong numbers Unit tests on fixtures with known-correct answers, not on 'it ran'
Documentation drifts from code Bidirectional assertion: documented-but-missing fails, implemented-but-undocumented fails
Model uses it wrong Isolated install, clean session, natural-language prompt, inspect the commands it actually ran

Assertions instead of promises

Documentation drifts silently. A table that says '12 kinds of checks' when the code emits 11 is worse than no table, because it makes the report look complete. So the count is asserted, in both directions:

documented  = set(re.findall(r"`([A-Z]{3}-\d{3})`", skill_md))
implemented = set(re.findall(r'code="([A-Z]{3}-\d{3})"', source))

assert documented - implemented == set()   # doc ahead of code -> fail
assert implemented - documented == set()   # doc behind code -> fail

The same rule covers the numbers in the docs about the bundled examples: which severity split, how many defect classes. If a check is added and the fixture starts reporting differently, CI fails instead of the documentation quietly becoming wrong.

def test_broken_covers_ten_codes(self):
    _, findings = self._lint(EXAMPLES / "demo_broken.dbc")
    codes = {f["code"] for f in findings}
    self.assertEqual(len(codes), 10)       # a wrong count fails the build
    self.assertEqual(codes, EXPECTED_SET)  # the sets must match exactly

Fixtures that double as an environment check

The two bundled DBC files are shipped inside the skill, so an agent can verify the install before touching a real matrix:

python3 <skill>/scripts/dbc_analyze.py lint <skill>/examples/demo.dbc         # expect 0 error 0 warn
python3 <skill>/scripts/dbc_analyze.py lint <skill>/examples/demo_broken.dbc # expect all 10 defect codes

A clean fixture whose bit assignments are computed by the generator rather than typed by hand, and a broken one that deliberately trips every check family. If the first one stops being clean or the second stops being fully detected, something regressed — no human review needed.

Acceptance: letting the model drive

Everything above still only proves that the script runs. It does not prove the skill works — because the skill is not the script, it is the script plus its instructions plus the model's interpretation of them.

So acceptance runs in an isolated profile, with the session cleared, driven by natural language, and then inspects the commands the agent actually executed:

# isolated profile: state goes to ~/.openclaw-skilltest, own workspace too
openclaw --profile skilltest skills install <skill-dir>

# a real user sentence, not a command
openclaw --profile skilltest agent --local --agent main \
  -m "check the hood_v14.dbc communication matrix for problems"
Prompt Commands the agent chose
"check this matrix for problems" info → fix-encoding → lint → messages → signals --frame
"what is different between these two" transcodes both, then diff old new with the baseline in the right position

The second row is the interesting one. The skill's Step 2 says 'encoding before anything else'; the agent applied that principle to both files on its own, which the file never spells out. That is the difference between an agent following a list and an agent following a rule.

Acceptance criterion

Installing next to the repository proves nothing: a relative path can resolve by accident. The only meaningful test is the one a future user will run — a clean environment, where the repository is not nearby.

Results and reusable method

Applied to two production matrices from the same vehicle network, one revision apart: 10 errors, 49 warnings, 42 infos — and the reverse direction reports the same ten errors, which is what says the comparison is symmetric rather than one-sided.

$ dbc_analyze.py diff EHX_Car_FLM_v1.0.dbc EHX_VIU1_HOOD_v1.4.dbc

old  21 messages / 379 signals
new  26 messages / 297 signals

[E] DIF-CRC  VIU_Info_Checksum: checksum data id changed   (0x1D2)
      0x61D2 -> 0x41D2
[E] DIF-ID   NM_Autosar_FLM_R: CAN id changed   (0x457 -> 0x46B)
[E] DIF-ID   NM_Autosar_FLM_L: CAN id changed   (0x45C -> 0x46A)
[E] DIF-REN  signal renamed   VIU1_SourceNodeIdentifier -> VIU1_NodeAddress

summary: 10 error, 49 warn, 42 info, 101 total
TEST COUNT OVER THE ITERATION 33 v1.0 37 +doc guard 58 +diff 61 +acceptance 101 FINDINGS IN THE REAL DIFF 10 err 49 warn 42 info reverse diff → same 10 errors direction symmetric manual cross-check ≈ 30 min this tool 0.1 s repeatable, and CI-ready
Same ten errors in both directions; the three that matter most are invisible in a normal diff.

The three most dangerous findings are all invisible in a normal diff: a CRC data id that moved, and two network-management frames that changed address while keeping their names. Every receiver would have to be rebuilt. That is the payoff of designing for the domain instead of for the format.

The method, in five lines

Step Question
1 · SplitWhich parts must be reproducible, and which need judgement?
2 · EncodeWhat does the domain know that a model would re-derive differently each run?
3 · ShapeStep 0 first. What does the model need in order not to ask?
4 · BoundWhat can this never see, and who must check it?
5 · ProveIs every claim in the docs asserted by a test, and does a model in a clean environment pick the right commands?

None of those five steps is about writing more code. Four of them are about deciding what not to automate, and how to make the remaining claims verifiable. That is the part that transfers to the next skill.

Code, tests, and the design in full

Standard library only, MIT licensed. Fixtures are synthetic — no real vehicle, supplier, or personal data from any customer matrix.

github.com/autosar-skills/autosar-agent-skills

Common questions

What separates a skill from a prompt?

A prompt persuades; a skill constrains. The skill has a Step 0 that decides which branch applies before anything runs, an execution order whose sequence has a stated cost if reversed, and output that is either asserted by a test or explicitly listed as unverifiable. If removing the model would not change what happens, it was a tool, not a prompt — put it in code.

Why not just let the model write the parser each time?

Because it re-derives the same domain facts differently every run. Bit geometry, the PduR routing chain, which vendor uses which container name — these are facts, not judgement. Encoding them once means the model cannot get them wrong on a Tuesday, and a wrong answer becomes a failing test instead of a plausible sentence.

How do you test a skill that is mostly instructions?

Assert the parts that are mechanically checkable and be explicit about the rest. Documented check codes must equal the codes the code can emit, in both directions — that caught a code written in the docs but never implemented. Everything a test cannot reach gets listed as 'must be verified by a human', which is the section most skill documents omit.

Why does the skill document read like a runbook instead of a README?

Because its reader is a model, not a person. The model does not need to be told why the design is good; it needs to know which branch to take and in what order. Explanatory material moves to README.md or references/, where it is loaded only when needed. A quick check: if SKILL.md contains no Step 0, it was probably written for humans.

Harry Zhao
AUTOMOTIVE EMBEDDED · AUTOSAR BSW / PLATFORM TOOLING

12 years in automotive embedded software, from ECU low-level drivers up to AutoSAR CP platform architecture. Builds agent skills for the problems that survive a clean compile: communication matrices that disagree with the ECU configuration, and delivery pipelines nobody can reproduce by hand.

Hand-written HTML / CSS · Cloudflare Workers ← All notes me.tech-harry.top