EN

Otter

A Claude Code agent for the control room: it writes the tuning routine, you approve, Badger runs it.

这一页还没有中文版,先放英文原文。

The software that annoyed me most was my own. I created Badger in 2021 and led it through 2025 so an optimizer could run in the control room, and it does, but the window I put around it takes time to learn. Rerunning a saved routine is one click. A new job, even one started from a template, still means picking the environment, setting the variables and writing the objectives and constraints, and an algorithm you have not run before comes with its own pile of hyperparameters. So Otter started narrow. Say "please tune up the FEL intensity", the way you talk to Claude Code, and let an agent turn that one sentence into a routine you approve and Badger runs.

Then I named it Otter, OperaTor in TERminal, and the name is what widened it. A control room always has a terminal, and so does an ssh session into it, even when there is no desktop. The data and the software that reads it are already there, so a new job is mostly one more tool. Tuning was just the job I wanted fixed first.

In October 2025 I drew an agent loop of my own, a classifier out front, then a planner, then the tools. It never got built. What runs instead is Claude Code itself, with the tools and skills I write for each job, under the safety layer of Osprey, LBNL's open-source framework for agents in control systems. The agent never writes to the machine directly.

Most of what a routine needs is in the archive or in somebody's head, and what I did not know was whether the looking-up could be quick enough that anyone on shift would wait for it. The first try on a real machine was an overnight session at LCLS on 14 May 2026. LBNL's IPAC'26 status paper on Osprey (I am one of its seventeen authors) describes the LCLS tuning in one paragraph. The numbers here come from the more detailed shifts after that, in June at LCLS and in July at FACET-II, both SLAC accelerators.

Otter on the control-room host

Otter runs under an ordinary account on the control-room host, no container. The host has no internet and nothing installs there, so I build the whole tree on a cluster and push it in. You lose reproducibility that way. Strictly, Otter is SLAC's installation of Osprey (I am Osprey's #2 code contributor), and what I wrote on top is the MCP servers and tools, the skills, the knowledge pack and the deployment.

The skills are Markdown documents you can read and edit. The smallest, /propose-routine, composes one routine. /auto-tune strings several into a staged campaign inside one Badger window, and for FACET-II there is /auto-tune-facet-yaml, which hands the facility's verified runner a whole start-up as YAML. The skills call three MCP servers I wrote, with a few dozen tools between them, and every value they hand back says where it came from. A small offline knowledge pack covers what the archive cannot, and when sources disagree the live reading wins, then the archive, then the notes.

The agent's own control-system interface has writes disabled, so it cannot set a process variable itself. It can read anything. Shell and web are refused outright, and anything that would move the machine has to be approved first. At LCLS that meant every save and every launch, and at FACET-II the whole workflow at once. The only path to the machine is a bounded Badger run, with the starting state saved so the operator keeps the result or reverts it, or a runner step that restores its snapshot if it fails. For Otter I added a small remote-control surface to Badger on a branch.

Architecture: the operator gives Otter, a Claude Code agent on the control-room host, one sentence; Otter calls MCP tools through a fail-closed PreToolUse gate; the tools read the run archive, live values and a small knowledge pack, and launch a bounded Badger run or a facility-runner step; only that bounded run touches the LCLS and FACET-II control system, and the operator accepts or reverts the result Operator on shift Otter · Claude Code on the control-room host MCP tools I wrote Badger window / facility runner LCLS · FACET-II one sentence in, approves at the gates same Badger window, same Stop button ordinary account · no internet only model traffic leaves, via the site proxy skills are Markdown: propose-routine · auto-tune auto-tune-facet-yaml Badger run archive live values (read-only) routine drafting knowledge pack: 6 notes, keyword search FACET-II stages · verified runner every value says where it came from live read > past run > document bounded run: ranges · step limits state saved at start operator accepts or reverts EPICS only the bounded run touches it a sentence in · plan and asks back routine in · evaluations back setpoints · readbacks live values, read-only · writes disabled tool calls agent never writes a PV itself PreToolUse gate (fail-closed) writes disabled · limits · ask the human
Every tool call passes a fail-closed gate on its way out, and the control system on the right only ever sees a bounded Badger run or a runner step.

The June shift at LCLS

We were on the hard X-ray line on 26 June 2026. Just after ten the operator asked for maximum FEL signal, with the loss monitor as a soft constraint and the algorithm left to the agent. It ranked knobs over some five hundred archived runs, and four matching quads came out far ahead. It read the loss-monitor candidates live and kept the one that answered with a real value (the map and the operators' notes disagree on which is which). The operator okayed the three-stage plan and the familiar Badger window came up on the console.

When stage one flattened out the agent asked Badger to stop, and Claude Code's permission classifier refused it three times, roughly 28 evaluations of beam time. It waited and asked. The operator typed "please go ahead and I'll approve", the stop landed, and stage two swapped into the same window. That bought half a percent, so the agent cancelled its own third stage and quit at 22:27. Pulse energy went from 0.468 to 1.819 mJ. Most of that is recovery, I should say, because the campaign started in the middle of a sag; it still finished about 4% above the operators' own section sweep earlier that evening.

Two stacked panels over 96 evaluations. Top: FEL pulse energy rising from about 0.5 to 1.8 mJ through a UCB stage on four LTUH matching quads and an EI stage on eight LI26 quads, with a dotted line at the operators' best that evening, an empty third stage and a marker where the agent stopped. Bottom: the undulator beam-loss monitor over the same evaluations, staying well inside its soft floor.
Both stages ran in the same Badger window (UCB on four matching quads, then EI on eight transport quads), and the third sits empty because the agent called it off; the dotted line is the operators' best that evening, with the loss monitor below against its soft floor.

An extremum-seeking polish earlier that evening had almost nothing in the archive to copy, so the agent worked from a note I had distilled from the literature (the published values would have been wrong for the optimizer we have installed). That run took a hundred-odd evaluations to reach 1.786 mJ, the best single run before the campaign. Over the evening it composed eighteen routines, each from a one-sentence request.

Two single runs on the same four LTUH matching quads side by side, UCB on the left and extremum seeking on the right, objective on the top row and each quad's setpoint change from its first evaluation on the bottom row, on one shared scale.
Two runs from one request each on the same four matching quads (Q1 to Q4), UCB on the left and extremum seeking on the right; the bottom row is what the quads did (BO jumps, ES dithers).

Five start-ups at FACET-II

Injector start-up at FACET-II is six tuning problems in order, each already an Xopt routine behind a verified runner. The routines and the runner are SLAC's autonomous-control package. Nobody had automated the part around them. Before each step something has to check the machine is ready and pick parameters for the beam that is actually in it, and afterwards read the log and decide what happens next (sometimes that means waking a human). That layer is mine.

The operator's rule on the night of 1 July 2026 was "show me the YAML first". The agent wrote each start-up as a short YAML workflow, showed it, and on one keystroke ran all six phases with nobody between them. Five start-ups ran that way back to back, mostly at 1600 pC, and the ranges the agent composed there carried over unchanged to the two higher-charge runs. Runner time was 16 to 24 minutes each. The rest of the 3 h 18 min was spent outside the runner, between steps and between start-ups. One klystron phase would not settle. The agent read the traceback, called it a transient, relaunched the same workflow and was through about a minute later.

Panel a: cumulative runner time per phase for five FACET-II start-ups A to E at 1600, 1600, 2000, 2200 and 1600 pC, with a marker on the one failed step. Panel b: one small panel per phase (laser steering, Schottky timing, beam steering, energy spread, injector emittance, TCAV phasing), an arrow per campaign from starting value to ending value with error bars where the measurement has them, grey for the three phases that were not driving a declared objective down.
Runner time per phase for the five start-ups (the marker is the klystron step that failed and was relaunched), and on the right where each phase started and ended in each campaign, grey where there was no declared objective to push down.

Every phase finished, which is not the same as having tuned anything. The energy-spread phase cut the dispersive beam size by 17 to 86 µm, but within its own scan, which never measures the setpoint it inherited. In-loop emittance fell in four of the five start-ups, and no drop beats twice its own uncertainty, so I claim nothing there. The one reading I trust came from the facility's own emittance app, run outside the loop before and after start-up E. The geometric mean went from 5.34 ± 0.13 µm to 5.10 ± 0.05 µm, with horizontal down and vertical up, so it is a trade. E is the only start-up checked that way.

What broke

The convergence check silently parsed nothing after a schema key moved in the deployed optimizer, and reported no knob at a boundary; one quad sat pinned at its lower bound for most of stage one, best point included. FACET-II verification only checked that a step completed, so a step that exited cleanly counted as a pass whether or not it hit its target. The operator corrected the same choice four times that evening (penalize beam loss, do not abort), and none of it survived into the next session.

In four FACET-II sessions the agent looked at disconnected readbacks and announced it was running against a simulation. It was wrong. The beam was live throughout, and most of the names it called disconnected it had made up. A confident sentence like that in an operations log is a failure no tool gate catches, and it is on my list.

Numbers are from the Badger run archive, the runner logs and the session transcripts, June and July 2026. The full write-up, with the ALS team, is in preparation.