Hire Me

Letting an agent talk to my ECUs

Mickey · May 8, 2026

Something has changed for me at the bench in the last few weeks, and I want to write it down before it stops feeling new.

I have spent the last decade building or using diagnostic tools that put a human between the agent of investigation and the bus. CANcorder, Swift-CANyonero, my Linux toolboxmcandump, mcangen, canconf — countless one-off scripts. The idea was always the same: give the human a clear view of what the bus is doing, and let them ask the next question. The next question is the interesting part.

Generative AI has been around the bench for a while too, but mostly as a thing that read my logs after the fact. Help me decode this byte. Help me write this DBC. Help me understand why an ECU answers 7F 22 33. That is useful, and I do not want to talk it down. But it is post-mortem work — the agent is staring at a frozen photo of a session that already ended.

What I wanted, and never quite got, was an agent that sat at the bench with me. One that could send a frame, watch what came back, decide what to ask next, and tell me what it learned in the same loop where I am thinking. Not a replacement for me. A colleague.

The thing that has changed is that this is now real. I built a small piece, plugged a Scania S8 truck into it, and asked an agent to find the VIN. It did. From inside a chat box.

I am still a little stunned by how good that feels.

Why a shell is the wrong boundary

Watching an LLM drive candump and cansend through a shell, parsing hex out of a scrolling terminal, is an exercise in watching a smart system spend its energy on the wrong layer. Every CAN frame becomes a string. Every reply becomes a regex. Every multi-frame ISO-TP transfer becomes process orchestration. The agent burns most of its context window on plumbing and gets the timing wrong anyway, because by the time stdout is parsed the next frame has already arrived.

The right boundary is structured. Frames are already structured: ID, flags, DLC, payload, timestamp. ECUs already speak request/response. Filters are already a feature of the kernel. The only thing missing is a server in the middle that knows SocketCAN well enough to do the right thing on each side and present the agent with a typed RPC.

Anthropic’s MCP is exactly that shape. The agent calls a typed tool, the server does the work, the agent gets a typed result. JSON in, JSON out, no shell in between. Once I tried it for one CAN tool, I could not stop wanting it for the rest.

mcanbus

The library came first.

I had two existing tools — mcandump and mcangen — that both reimplemented SocketCAN from scratch using raw libc. That was a deliberate choice at the time: the existing socketcan crate on crates.io is fine, but it pulls in optional async runtimes and abstracts the kernel a little further than I wanted for tools that move millions of frames per second. Hand-rolling raw socket code twice gives you an itch though. Both tools shared the same CAN_RAW open sequence, the same recvmmsg/sendmmsg patterns, the same cmsg walk for SO_TIMESTAMPING, the same netlink dance for RTM_NEWLINK and IFLA_CAN_STATE. I kept editing two copies of essentially the same code.

The MCP server gave me a real reason to factor that out. The result is mcanbus — a SocketCAN crate that stays close to the kernel:

That last bit was my litmus test. If a SocketCAN crate cannot survive eight subscribers fanning out a saturated bus without losing a frame, it is not the crate I want. This one survives it without breaking sweat: validated against real hardware at 5000 fps for three seconds, every subscriber saw exactly the same 15 028 frames, no drops, all queues drained at exit.

There is also netlink. Interface::set_up, set_down, cycle, state. The cycle call is the BUS-OFF recipe for gs_usb-class adapters: bring it down, sleep 150 ms, bring it back up, because the kernel will not restart these devices on its own. That code lived in mcangen’s main file; pulling it into the library means anyone touching gs_usb hardware gets it for free.

socketcan-mcp

The server itself is small. About 500 lines of Rust on top of rmcp — Anthropic’s own Rust SDK for MCP, which has reached the point where you declare a struct, decorate methods with #[tool(description = "...")], and the schema generation, RPC routing, and stdio transport are taken care of for you.

Five tools to start:

ToolWhat it does
list_interfacesEnumerate every CAN-class interface with state and bitrate.
iface_stateDetailed status (up/down, controller state, bitrate) for one interface.
captureListen for up to N ms and return up to M matching frames.
send_frameTransmit a single frame.
send_and_captureTransmit and immediately capture replies in the same call.

All write tools are gated by an environment-variable allowlist. Setting SOCKETCAN_MCP_INTERFACES=can0,vcan0 is the only way to permit sends; an empty allowlist is the safe default. SOCKETCAN_MCP_READONLY=1 reduces the surface to the read-only tools regardless of allowlist. There is no config file, no hidden state, no surprises. The server has no global mutable state of its own — every tool call opens its own sockets and tears them down.

The Scania moment

This is the part I want to remember.

I have a Scania S8 — the R-series cab, KWP2000 over ISO-15765 extended addressing — connected to my bench through two USB-CAN adapters wired to the same physical bus. Diagnostic side, not powertrain. I wanted a test that was not synthetic: a real ECU, real protocol, real timing. So I plugged the truck in, sat down at my editor, and asked the agent to find the VIN.

The first attempt was deliberately the hard way. The MCP server at that point had only the five tools above. No ISO-TP. The agent had to do the segmentation and flow control by hand.

It opened with a recon capture. Listened to can0 for a couple of seconds, saw the bus shape, decided which IDs to use. Then sent the standard KWP request — service 0x1A, local identifier 0x90, ISO-TP single frame on 0x18DA00F9 (tester 0xF9 to target 0x00):

TX  18DA00F9   02 1A 90 CC CC CC CC CC

The ECU answered with a First Frame on 0x18DAF900:

RX  18DAF900   10 13 5A 90 59 53 32 52

Twenty-four bits of decoding work for the agent. The PCI nibble 1 says First Frame. The next 12 bits 0x013 say total length 19 bytes. The first two payload bytes are the KWP positive-response header: 5A is service 1A echoed with the high bit set, 90 is the local-identifier echo. The remaining four bytes are the first piece of the VIN: Y S 2 R.

YS2 is Scania’s manufacturer prefix.

The agent sent the Flow Control by hand:

TX  18DA00F9   30 00 00 CC CC CC CC CC

Two consecutive frames came back:

RX  18DAF900   21 36 58 34 30 30 30 35     "6X40005"
RX  18DAF900   22 34 31 32 37 33 35 00     "412735"

Reassembly is trivial once you have the frames. The agent put them together: YS2R6X40005412735. Seventeen characters. Valid Scania VIN. R-series cab, 6×4 drive configuration, the rest is plant code, model year, serial.

That entire session — recon, send, decode, flow control, reassemble — happened in maybe thirty seconds of agent time. Four MCP calls. No shell, no candump, no regex, no race conditions. The agent decoded the FF length field correctly, knew it had to send Flow Control, knew the KWP positive-response header, formatted the bytes back as ASCII when I asked.

isotp_request

After that worked, the obvious next step was to give the agent ISO-TP as a primitive. Four MCP calls is fine for a demo; it is not what you want when the agent is in a tight diagnostic loop with twenty different ECUs.

So mcanbus got an isotp module. Synchronous request/response, automatic Single Frame / First Frame / Consecutive Frame segmentation, Flow Control in both directions, ECU-side BS=0 supported, BS>0 returns Unsupported for now. Twelve unit tests for the encoding edge cases including the exact byte sequences I had just observed on the Scania bus.

The MCP server got a sixth tool, isotp_request. Same VIN read, one call:

{
  "name": "isotp_request",
  "arguments": {
    "iface": "can0",
    "tx_id": "18DA00F9",
    "rx_id": "18DAF900",
    "extended": true,
    "payload": "1A90"
  }
}

Response:

{
  "duration_ms": 1,
  "response": {
    "len": 19,
    "hex":   "5A905953325236583430303035343132373335",
    "ascii": "Z.YS2R6X40005412735"
  }
}

One call, one millisecond. The library handles the segmentation, the agent gets the reassembled payload back. The leading Z. in the ASCII column is just the KWP header rendered verbatim — 0x5A is Z, 0x90 is non-printable.

That is the shape I want for diagnostic work from now on.

What this changes

The thing I keep coming back to is that the agent is not a nicer terminal. It is a colleague who reads everything I send back, remembers what we tried, notices patterns, suggests the next request. Every time the bench gets a new structured tool, that colleague gets sharper. Every time I make them parse candump output, that colleague gets stupider.

A short list of what I now do from inside a chat that I used to do from a shell:

None of this replaces CANcorder for live inspection or Swift-CANyonero for building deep diagnostic stacks. It replaces the tmux window where I used to type four-letter commands at three in the morning when something unexpected was hiding on a bus. That window is the one that matters for exploratory work, and it now has someone in it who can read.

The pieces

Both crates are MIT-licensed, on GitHub, and on crates.io:

mcandump and mcangen will migrate onto mcanbus in a follow-up — same wire-level behaviour, less duplicated code. That part is paperwork.

The deeper work is on the agent side. Once I had a reliable structured channel between an agent and a CAN bus, more uses surfaced than I had originally drafted as tools. Long-running capture sessions backed by the fan-out reader. ISO-TP servers, not just clients, so the agent can imitate an ECU. DBC decoding, so frames come back as named signals. A first-class CAN-FD ISO-TP path. None of those are hard; all of them are clearly worth building now, where before they would have been bench scripts I never quite finished.

Closing the loop

The thing I underestimated is how much fun this is. For years the bench was a place where I read frames, decided what to ask next, sent the request, read the response — all in my head, with my fingers, in a terminal. None of those steps were hard. They were just mine. Closing that loop with a colleague who can read and decide alongside me has changed the texture of the work in a way I did not see coming. The bench is suddenly conversational, and the conversation is about the actual problem, not about the formatting.

I am, at the same time, both fascinated and a little uneasy at how fast this wheel is turning. mcanbus and socketcan-mcp together took a long weekend. Two years ago I would have called the same thing science fiction. Five years ago I would have called it impossible. The shape of “structured tool-calling between a language model and a real piece of hardware” is barely a year old, and it is already production-shaped enough to read a VIN off a Scania ECU in a millisecond. Whatever bench work I will be doing two years from now almost certainly does not exist yet today, and that is exhilarating and slightly disorienting in roughly equal measure.


CANsole is my forthcoming inspection-and-decoder side of the same toolchain — a desktop CAN debugger and simulator for working engineers who need to look at a bus, log it, decode transport protocols, and understand what an ECU is actually doing.

The Linux side, now, has a colleague.