circuitRF Reference Guide

Writing a Plugin

Everything a plugin author needs - the manifest, the panel, every message, view and host call - complete enough to hand to an AI assistant and get back a plugin that installs and runs.

This page is the contract. It is written for an engineer who will often hand it to an AI assistant along with one of the example plugins, so every table here is complete: if a field is not on this page, circuitRF does not read it. Using Plugins covers installing and running them.

What you are writing

A plugin is a separate program in a folder with a plugin.json beside it. circuitRF starts the program, talks to it over its standard input and output, and never loads its code. That gives you three things for free: your plugin cannot crash circuitRF, it can be written in any language under any licence, and it does not break when circuitRF's own code changes — only the messages on this page are the contract.

circuitRF draws; your plugin describes. You return numbers, a plot description, shapes in database units, a netlist. circuitRF draws them with the same renderers, colours, zoom and markers as the rest of the application. Your plugin writes no drawing code and no mouse code.

There are three kinds:

kind What the user sees Use it for
panel A tool window circuitRF builds from your manifest: your parameters, readouts, buttons and views, recalculated as the user types Almost everything: synthesis, calculators, generators
action A parameter dialog, then one run that places a cell A generator with no preview
window Your own program's window. circuitRF draws nothing and only listens An existing desktop tool, an instrument front panel, anything the panel vocabulary cannot express

Every plugin ends the same way: it writes documents into a staging folder circuitRF gives it, and circuitRF places them in the workspace — validated, behind a restore point, with the Replace / New name question. A plugin never writes into the workspace itself.

The smallest plugin

A folder with two files. plugin.json:

{
  "apiVersion": 1,
  "id": "example.quarter-wave",
  "name": "Quarter-Wave Transformer",
  "version": "0.1.0",
  "kind": "panel",
  "run": { "python": { "entry": "qw.py", "requiresPython": ">=3.8" } },
  "params": [
    { "name": "zl", "label": "Load", "kind": "quantity", "unit": "Ohm", "default": "100 Ohm", "min": "1 Ohm" },
    { "name": "zt", "label": "Line impedance", "kind": "readout", "unit": "Ohm" }
  ]
}

qw.py, using the helper package (copy tools/plugin-python/circuitrf_plugin/ from the circuitRF repository into the folder beside it — it is standard library only):

import math
from circuitrf_plugin import Plugin, Reply

plugin = Plugin()

@plugin.update
def update(p, ctx):
    return Reply(readouts={"zt": math.sqrt(50.0 * p.zl)})   # p.zl is the load in ohms, base SI

plugin.run()

Link the folder (Settings ▸ Plugins ▸ Link Folder…), allow it, and open it from Tools ▸ Plugins. With no ui in the manifest you get the default panel: the parameters in a column, then one tab per view your first reply contains. The same plugin runs headless with circuitRF plugin run example.quarter-wave --set zl="75 Ohm" -o out.

The manifest, plugin.json

Field Required Meaning
apiVersion yes The message set you wrote against: 1. A whole number
id yes Unique, reverse-DNS-like: lower-case letters, digits, . and - only, starting and ending with a letter or digit (yourname.coupled-filter). The key for permission, the list, the CLI and every result
name yes What the menu shows
version yes Your version, e.g. 0.3.0. An update is a new version
kind yes panel, action or window
run yes How to start your program, per platform (below)
summary One sentence: the menu tooltip and the list
author, license, homepage Shown in the permission prompt and Settings
minCircuitRF The oldest circuitRF it needs, e.g. 1.8.0. An older circuitRF lists the plugin dimmed and does not start it
menu The submenu of Tools ▸ Plugins it appears under (Filters, Calculators)
contexts Also offer it from a right-click: any of cell, layout, result, technology
params The parameters (next section). Required for a window plugin's headless form
ui The panel's layout tree, for kind: panel. Leave it out for the default panel
timeouts {"update": s, "action": s, "commit": s} in seconds (at most 86400, a day), to replace the deadlines

An unknown optional field is ignored, never refused, so a manifest written for a later circuitRF still reads.

run has one entry per platform, keyed by .NET runtime identifier, or one python entry that runs everywhere:

Key Entry Started as
python {"entry": "my_plugin.py", "requiresPython": ">=3.10"} <python> -u <entry>, with PYTHONIOENCODING=utf-8, on the Python circuitRF finds for kit scripts (the first python3 on the PATH)
win-x64, win-arm64, win-x86 {"exe": "bin/win-x64/tool.exe"} The program itself
osx-x64, osx-arm64 {"exe": "bin/osx-arm64/tool"} The program itself
linux-x64, linux-arm64 {"exe": "bin/linux-x64/tool"} The program itself

Paths are relative to the plugin folder and must stay inside it. The working directory is the plugin folder. A platform with no entry lists the plugin dimmed with the reason. A Python plugin may ship requirements.txt beside the manifest; circuitRF installs it at install time (see Using Plugins).

Parameters

Parameters are declared once, separately from where the panel shows them, because the command line and MCP need them with no panel. Each is {"name", "kind", …}; a name is an identifier ([A-Za-z_][A-Za-z0-9_]*) and unique.

kind Fields The user gives Your plugin receives
quantity unit (required), default, min, max, chars A value with an SI prefix (2.4 GHz, 2.4G, 2.4e9) or an expression {"value": 2.4e9, "unit": "Hz", "text": "2.4 GHz"} — value in base SI
int default, min, max, chars A whole number The number
real default, min, max, chars A number The number
bool default A check box true / false
string default, chars Text The text
choice options (required), default A picker The chosen option's text
file extensions (e.g. [".s2p"]), chars A file picker The absolute path
substrate A picker over the workspace technologies' conductors The resolved substrate (below)
technology A picker over the workspace's technologies {id, path, dbuPerMicron, layers: [{name, layer, datatype, purpose}], stackup} — stackup as the substrate's, below
cell A picker over the workspace's cells {path, name, views}
result A picker over the workspace's Touchstone files and run .npy {path}, plus ports, fmin, fmax for a Touchstone file
readout unit (optional) Nothing: your reply fills it Never sent

Every parameter takes an optional label (what the panel shows; the name stays the identifier).

The substrate object — what a substrate parameter delivers, and what resolve substrate returns:

Field Meaning
technology The .ctech path, absolute — resolve technology takes it as name for the layer numbers
dbuPerMicron The technology's database units per µm, for scaling your shapes
signal, ground The signal conductor's and the ground reference's names in the stackup
h Dielectric height between them, metres
er, tanD Relative permittivity and loss tangent
t, sigma, roughness Conductor thickness (m), conductivity (S/m), RMS roughness (m)
stripline true when there is a ground above as well
stackup Every stackup entry, top to bottom: {kind, name, thickness} with thickness in metres and kind one of conductor, dielectric or via; er, tanD, mur on a dielectric; sigma, isGround, roughness on a conductor; and on any entry drawn on layers, layers — the drawing layer names. Skip a kind you do not use: more may be added

Shapes in a layout view name drawing layers (Top Copper), which are not the stackup conductor's name (Top Copper (1 oz)): take the layer from the conductor's layers.

The panel: a layout tree

ui is {"title", "size": [width, height], "layout": <node>}. A node is an object naming one container or one leaf.

"ui": {
  "title": "Single-Stub Match",
  "size": [1180, 760],
  "layout": { "split": "horizontal", "sizes": [1, 3], "children": [
    { "column": [
      { "group": "Load", "children": [ { "param": "f0" }, { "param": "rl" } ] },
      { "readout": "summary", "as": "text" },
      { "messages": true },
      { "button": "Place in workspace", "action": "commit" }
    ]},
    { "tabs": [
      { "title": "Response", "view": "response", "kind": "plot" },
      { "title": "Layout",   "view": "artwork",  "kind": "layout" }
    ]}
  ]}
}

Containers

Node Meaning
{"row": [...]} / {"column": [...]} Children side by side / stacked
{"group": "Title", "children": [...]} A titled box
{"tabs": [{"title": "…", …a leaf or a container}]} Tabs; each tab is a node with a title added
{"split": "horizontal" \| "vertical", "sizes": [1, 3], "children": [...]} A resizable split; one size per child, as proportions

Leaves

Node Draws
{"param": "f0"} The parameter's label and control. "label" and "chars" override the declaration's
{"readout": "lengths", "as": "value" \| "table" \| "text"} A readout parameter your reply fills; as defaults to value. "label" overrides
{"button": "Label", "action": "name"} Sends action {name}. "commit" is the reserved placement action
{"messages": true} Your messages for the current parameters
{"view": "name", "kind": "plot" \| "smith" \| "layout" \| "schematic" \| "image" \| "table"} A view your reply fills
{"note": "text"} Fixed text, one or two lines

A view name is placed once. An element or view kind this circuitRF does not know is drawn as an empty frame and noted, not refused. circuitRF holds the panel's rules, so you cannot break them: controls never shift (a widget you disable is greyed, never hidden; an empty view draws an empty frame with the reason), colours and fonts are the theme's, and the panel remembers its size and split positions per plugin.

Units: base SI, and DBU for shapes

Physical values cross in base SI: hertz, ohms, metres, farads, seconds. A quantity arrives as base SI, a readout number is read as base SI in its parameter's unit, a table cell is base SI in its column's unit, a plot axis is base SI. You never format a number for display — circuitRF does, in the user's units — and you never parse "2.4 GHz".

Layout geometry crosses as integer database units (DBU), as int64 in the frame's binary part: 1 DBU is 1/dbuPerMicron µm, and dbuPerMicron is the technology's (1000 today, so 1 DBU = 1 nm). Convert each coordinate once, rounding half away from zero (round(metres · 1e6 · dbuPerMicron)), and do arithmetic in DBU after that. The reason is the same as for circuitRF's own layout: a fractional coordinate cannot be expressed, so it can never be silently rounded by whoever reads it, and a sum of rounded values cannot drift.

The conversation

The frame

Every message, both ways, is one frame on stdin (circuitRF → you) or stdout (you → circuitRF):

[ uint32 jsonLen ][ uint32 binLen ][ jsonLen bytes of UTF-8 JSON ][ binLen bytes of int64 ]

Both lengths and every int64 are little-endian. binLen counts bytes (a multiple of 8). The binary part is empty except in a reply carrying a layout view. Writing a plugin in C# shows two frames byte by byte.

Messages

Every message is one JSON object with a "type" naming it and its fields beside it. Requests from circuitRF carry an integer id, counted up from 1 per session; your answer copies it. A message of a type the other side does not know is a protocol error, because the version was agreed in hello.

circuitRF → plugin

Message When You answer
hello {id, apiVersion, circuitRF, platform, staging, workspace, context, mode} Once, first hello {apiVersion} — the version you will speak (≤ the one offered); it needs no id
update {id, params} The panel opens, and after any parameter change (debounced 150 ms) reply
action {id, name, params} A button reply
event {id, view, kind, …, params} A bound marker or handle is dragged, an image is clicked reply
commit {id, params} Place in workspace, or plugin run --place placed
cancel {id} A newer request supersedes id, or the user pressed Stop Nothing required — see below
bye The panel closes, or circuitRF exits Exit within 2 s, or you are killed
hostReply {id, …} The answer to your host call —
placedReply, openReply The answers to a window plugin's placed and open —

hello's fields: platform is this machine's runtime identifier; staging is your staging folder (absolute); workspace is the workspace folder, or null; context is where you were opened from — {workspace, cell | layout | technology | result, path} when it was a right-click; mode is interactive (a window) or headless (circuitRF plugin run).

plugin → circuitRF

Message Fields
reply id, and any of: readouts {name: value}, views {name: view or null}, messages [{severity, text}], enabled {name: false}, set {param: value}
placed id, cells: [{name, files, layout?}], optional messages
progress id, fraction (0–1, optional), text (optional) — before your reply, shown on the panel's busy bar
host id (the request you are answering), call, and its arguments — before your reply (Host calls)

params in update, action, event and commit is an object keyed by parameter name, each value as Parameters says it arrives:

{ "type": "update", "id": 4,
  "params": { "f0": { "value": 2.4e9, "unit": "Hz", "text": "2.4 GHz" }, "order": 5, "substrate": { "technology": "…", "signal": "Top Copper (1 oz)", "h": 7.62e-4, "…": "…" } } }

What a reply's fields mean.

Events. kind: "marker" carries marker (its index in the plot's markers) and x (base SI); kind: "handle" carries param and value (base SI, solved by circuitRF); kind: "click" carries x and y in the image's own pixel coordinates. A marker or handle changes its parameter: params already holds the new value, and the ordinary update follows your reply. If you have nothing particular to do for an event, answer it exactly as you answer update.

Deadlines. An update has 30 s; an action or commit 10 min (timeouts replaces them). Past it the panel says the plugin is not responding and offers Stop. Put anything slow behind a button (action), not in update.

Cancel. circuitRF sends cancel {id} when a newer request supersedes one, and ignores whatever you then answer to it, so a slow plugin never draws an old answer over a new one. You may ignore cancel and still be correct; honouring it only saves time. After the user presses Stop, a plugin that has answered nothing it was told to cancel within 2 s is restarted — so answer every request, even a cancelled one (an empty reply {id} is enough).

One process per panel. It lives until the panel closes. If it exits unexpectedly the panel says so, keeps the last views and starts it again on the next change, with a fresh hello.

Be a function of your parameters. The same parameters must give the same reply and the same staged bytes. circuitRF relies on it: the panel and plugin run must agree, and a test can call your plugin with no display.

Views

plot and smith

"response": {
  "data": {
    "axes":  { "freq": { "unit": "Hz", "values": [2.0e9, 2.01e9] } },
    "cubes": { "S11": { "axes": ["freq"], "kind": "complex", "re": [0.1, 0.09], "im": [-0.2, -0.19] } }
  },
  "plot": {
    "y": { "label": "dB", "min": -40, "max": 0 },
    "traces":  [ { "cube": "S11", "y": "db20", "label": "S11" } ],
    "markers": [ { "trace": "S11", "bind": "f0" } ],
    "lines":   [ { "y": -20, "side": "above", "xStart": 2.3e9, "xEnd": 2.5e9 } ]
  }
}

The user can zoom, add markers, export, and Open in Data Display, which writes your data and a .cdd into the staging folder and opens it as an ordinary document. None of that is your plugin's business, and you are not told.

table

"dims": { "columns": ["Section", "Width", "Length"], "units": ["", "metre", "metre"],
          "rows": [["Feed", 1.61e-3, 3e-3], ["Stub", 1.61e-3, 29.0e-3]] }

Numbers in base SI; circuitRF formats each column in its unit. "" is a plain number or text.

layout

"artwork": {
  "technology": "self",
  "shapes":  [ { "kind": "rect", "layer": "Top Copper", "xy": { "at": 0, "count": 4 } } ],
  "handles": [ { "parameter": "trim", "kind": "linear", "span": { "at": 4, "count": 4 }, "axisDeg": 90, "label": "Stub length" } ],
  "ports":   [ { "name": "1", "x": 0, "y": 0, "layer": "Top Copper", "width": 1610000, "outwardDeg": 180 } ]
}

Every shape and handle coordinate is a span into the frame's int64 payload: {"at": i, "count": n}, in int64 elements, n even (x, y pairs). A port's x, y and width, and lengths that are not coordinates (a path width, a radius), are JSON integers in DBU, refused with a fractional part.

technology is "self" (the technology your technology or substrate parameter names) or a .ctech path relative to the workspace. A layer is a drawing layer's name ("Top Copper") or {"layer": 1, "datatype": 0}. A layer the technology does not have is drawn on a fallback colour and named in the messages.

kind xy holds Other fields
rect 4 values: x1, y1, x2, y2 —
poly The outline, ≥ 3 points holes: a list of spans
path The centreline, ≥ 2 points width (DBU), end: flush, round, square or extended
rrect 4 values: x1, y1, x2, y2 cornerRadius (DBU)
circle 2 values: the centre radius (DBU)
via 2 values: the centre padSize, drillSize (DBU), optional landingLayer
label 2 values: the anchor text, height (DBU), rotation (R0, R90, R180, R270)
curve The outline edges (one per vertex: {"kind": "line"} or {"kind": "arc", "bulge": tan(sweep/4)}), holes

Every shape also takes an optional net.

Handles are grips the user drags to change a quantity parameter. span is exactly 4 values: anchor x, anchor y, grip x, grip y. kind is linear (a length along axisDeg, measured from the anchor) or angular (an angle about the anchor). Optional: label, min, max (parameter values, base SI — not coordinates). circuitRF solves the new value itself and sends an event {kind: "handle", param, value}: your plugin never maps a mouse position to a length. A handle on any other parameter kind is drawn and not draggable.

Ports are drawn as layout port labels and become real ports when the layout is placed: name (the port number as text), x, y, layer, width (DBU), and outwardDeg — the direction pointing out of the conductor (180 for a line's left end, 0 for its right).

The preview is read-only artwork with rulers, pan, zoom, hover position and shift-drag measurement.

schematic

"circuit": { "cnl": "circuit.cnl" }

A .cnl you wrote into the staging folder (The netlist format), drawn by circuitRF read-only.

image

"chart": { "file": "groupdelay.svg", "clickable": true }

A PNG or SVG in the staging folder, for a picture circuitRF has no view kind for. With clickable, a click sends event {view, kind: "click", x, y} in the image's own pixel coordinates (an SVG's user units). No hover, drag or zoom: this is the escape hatch, not a drawing API.

Host calls: work you ask circuitRF to do

While answering a request, send host {id, call, …} with that request's id, then read until hostReply with the same id arrives — its fields beside type and id, as the table's last column lists them (other messages, such as a cancel, may arrive first: keep them for afterwards). Several host calls may precede one reply, one at a time. A failed call is hostReply {id, error: "<sentence>"} and the session carries on. Results land in your staging folder, never in the workspace.

call Arguments Returns
lineModel line: microstrip, stripline or cpwg; substrate: the object a substrate parameter delivered, or "conductor : technology path"; width (m); gap (m, for cpwg); freq: Hz, a number or an array {z0: [...], eeff: [...], alphaDbPerM: [...], messages}, real numbers, one entry per frequency — circuitRF's own model of that line. messages is [{severity, text}], as in a reply: pass them on
simulate cnl: a staged .cnl; analysis: its analysis name (optional when the file has one) {result: <staged .npy>, touchstone: <staged .sNp, for a plain S-parameter run>, cubes: [names], messages}
resolve what: technology, substrate, cell or reference; name (a technology path; a substrate as "conductor : technology path", the path absolute or relative to the workspace; a cell folder); for a reference, ref and from {resolved}: the same object a parameter of that kind delivers. A technology with no name is the workspace's default
evaluate expr; optional unit (a base unit); optional path (a document whose variables are in scope) {value, unit}

Placing: what commit stages

On Place in workspace (or plugin run --place) circuitRF sends commit. Write your documents into the staging folder and answer placed {id, cells: [{name, files, layout?}]}:

To fail a placement, answer placed with no cells and an error message saying why; nothing is placed and the message is shown. The placed cell is a folder named after the cell holding a .ccell and its view folders. circuitRF validates every file, asks for the name (Replace / New name when it exists), takes a restore point named after your plugin and version, and places the cell. Any failure places nothing and lists every reason.

The netlist, in brief

A .cnl is one part per line: Type:Name node node … Param=value …. Node 0 is ground; any other node is a name you choose. A value carries its unit (50 Ohm, 3 mm, 1.5 pF); a length is written in mm or um, since m is milli. A line starting with ; is a comment. Stage a netlist with its analysis line, so the placed cell runs as it is. The parts a plugin most often needs:

Line Is
Port:P1 in 0 Num=1 Z=50 Ohm Port 1 between in and ground, 50 Ω reference
R:R1 a b R=100 Ohm, L:L1 a b L=2 nH, C:C1 a b C=1 pF A resistor, inductor, capacitor
TLIN:TL1 a b Z=50 Ohm L=12.3 mm Eeff=2.87 An ideal transmission line of impedance Z, physical length L and effective permittivity Eeff
fc = 2.4 GHz A variable other values can use
analysis SP1 type=sparam start=1 stop=6 npts=51 Unit=GHz An S-parameter sweep (start/stop in Unit)

The netlist format has every part and analysis; circuitRF reference prints them all.

A .clay, in brief

A panel plugin never writes one: it names its layout view in placed. A window plugin has no view to name, so it stages a .clay (the Python helper's clay_text writes one from a Layout). A line with a port at each end:

{
  "FormatVersion": 1,
  "DbuPerMicron": 1000,
  "Shapes": [
    { "$type": "Rect", "X1": 0, "Y1": -805000, "X2": 18439000, "Y2": 805000, "Layer": { "Layer": 1, "Datatype": 0 } },
    { "$type": "Label", "X": 0, "Y": 0, "Text": "1", "Height": 805000, "Rotation": "R0",
      "IsPort": true, "PortDirection": "R0", "PortLayer": { "Layer": 1, "Datatype": 0 }, "Layer": { "Layer": 1, "Datatype": 0 } },
    { "$type": "Label", "X": 18439000, "Y": 0, "Text": "2", "Height": 805000, "Rotation": "R0",
      "IsPort": true, "PortDirection": "R180", "PortLayer": { "Layer": 1, "Datatype": 0 }, "Layer": { "Layer": 1, "Datatype": 0 } }
  ],
  "Instances": []
}

Window plugins

A window plugin gets the same hello and then circuitRF draws nothing and only listens. Whenever your user asks, send, with an id of your own (count up from 1):

You send circuitRF answers
placed {id, cells} — placed exactly as above placedReply {id, cells: [the names they were placed under]} or placedReply {id, error}
open {id, path} — a document in your staging folder or the workspace openReply {id} or openReply {id, error}
host {id, call, …} hostReply, as above, with no deadline

Your ids are your own: count up from 1, one per message, and match each answer by it; they never collide with circuitRF's. Between hello and bye circuitRF sends a window plugin nothing but these answers. placed cannot name a layout view (a window has no reply for it to come from): stage a .clay. A window gets no parameters: to read a value the user typed with a unit, ask evaluate with unit ({"expr": "2.4G", "unit": "Hz"} → 2.4e9).

There is one instance per plugin: choosing it again while it runs says it is already running (circuitRF cannot raise another program's window). When circuitRF exits it sends bye, waits 2 s, then kills. A window plugin that exits by itself has been closed by its user.

A headless form. If the manifest declares params and your program answers update (readouts) and commit (placed) when hello's mode is headless — opening no window — then circuitRF plugin run and MCP can run it. Without one, plugin run refuses it, saying it "has no headless form". The headless update reply is a panel's reply: readouts, messages, and views too, which -o writes as files. Make both forms call the same code, and stage a .clay in both, so they stage the same bytes for the same parameters.

The Python helper, circuitrf_plugin

tools/plugin-python/circuitrf_plugin/ in the circuitRF repository: copy the folder into your plugin. It depends on nothing but the standard library (Python 3.8+), handles the frame, hello, cancel, progress, host calls and bye, and redirects print to stderr (at the descriptor, so a library writing to stdout cannot corrupt a frame either).

from circuitrf_plugin import Plugin, Reply, Placed, Plot, Smith, Layout, Table, Schematic, Netlist, error, warning, info

plugin = Plugin()

@plugin.update                      # every parameter change; also answers an event or action with no handler
def update(p, ctx): ...             # -> Reply

@plugin.action("simulate")          # a button whose action is "simulate"
def simulate(p, ctx): ...           # -> Reply

@plugin.event                       # optional: a dragged marker or handle, or an image click
def event(p, ctx, ev): ...          # ev is the message: ev["view"], ev["kind"], ...

@plugin.commit                      # Place in workspace
def commit(p, ctx): ...             # -> Placed(cells, messages) or a list of cells

plugin.run()
p["f0"] The parameter as delivered ({"value", "unit", "text"} for a quantity)
p.f0 A quantity's value in base SI; any other kind's value as delivered
ctx.staging, ctx.workspace, ctx.mode, ctx.headless, ctx.context, ctx.hello From hello
ctx.stage(name, text_or_bytes) Writes a file in the staging folder (UTF-8, \n line ends); refuses a name outside it; returns the name
ctx.path(name) The absolute path of a staged file
ctx.dbu(metres) Metres to DBU, rounded once, half away from zero, at the dbuPerMicron of the request's technology or substrate parameter (1000 otherwise; set ctx.dbu_per_micron to override)
ctx.progress(fraction, text) The busy bar; also a cancellation point
ctx.check() Raises Cancelled once the request is cancelled — call it in long loops. Let it propagate: the helper answers the cancelled request
ctx.line_model(line, substrate, width, freq, gap=None) lineModel
ctx.simulate(cnl, analysis=None), ctx.resolve(what, name=None, ref=None, from_=None), ctx.evaluate(expr, unit=None, path=None) The other host calls; a failure raises HostError with circuitRF's sentence
Reply(readouts, views, messages, enabled, set) A reply; a message is error(t), warning(t), info(t) or a plain string
Plot(title, x, y, y2, freq_unit, legend) / Smith(…) All optional (y={"label": "dB", "min": -40, "max": 0}). .axis(name, unit, values), .cube(name, axes, values) (complex values become re/im), .data_file(staged), .share_data(other), .trace(cube, y=…, label=…), .marker(trace, bind=… or x=…, draggable=…), .line(y, side, x_start, x_end)
Table(columns, units, rows) .row(*cells)
Layout(technology="self") .rect, .polygon, .path, .circle, .via, .label, .port(name, x, y, layer, width, outward_deg), .handle(parameter, anchor, grip, axis_deg, …) — every coordinate an int; a float is refused with a message naming ctx.dbu
Schematic(cnl), Image(file, clickable) The other views
Netlist() .part(kind, name, *nodes, **params), .analysis(name, **keys), .line(text), .text() — a .cnl with no hand-written lines
clay_text(layout, technology, tech_ref=None) A .clay from a Layout, for a window or action plugin with no view to name

A handler that raises is reported in the panel as an error message, and the session carries on.

A walk through the stub plugin

tools/plugin-examples/stub/ is the reference plugin: a single open stub matching a load to 50 Ω on a microstrip substrate. Its arithmetic is textbook, so every number in it can be checked by hand, and it uses every view kind. It speaks the frame itself rather than through the helper, so it shows the whole contract in one file.

plugin.json. Six parameters — f0, rl and xl (quantities in Hz and Ohm), substrate, trim (a length in metre, the stub's handle) and summary (a text readout). The ui is a horizontal split: the inputs, the readout, the messages and two buttons (Simulate in circuitRF sends action simulate; Place in workspace is commit) on the left; five tabs on the right — a response plot, a Smith chart, a dimensions table, the layout and the schematic.

stub.py, top to bottom:

  1. The frame: read_exact loops over partial reads; read_frame reads one message; send writes one and flushes. WAITING keeps messages that arrive while a host call is answered, so read_message returns them in order.
  2. host() sends a host call carrying the request's id and reads until its hostReply.
  3. The line: solve_width asks lineModel for the impedance of a guessed width (the closed-form microstrip equations give the first guess) and runs a secant search on ln(width) to 50 Ω, then rounds the width to a whole DBU and asks once more for the effective permittivity at that width. So the line it draws is circuitRF's 50 Ω line.
  4. The match: design() turns the load into a line length d and a stub length l (the formulas are in the file's header), plus the trim.
  5. The views: sweep() puts the response on a 1-2-5 step so a dragged marker lands on round frequencies; data() is the S11 cube; artwork() returns the shapes and the payload — two rectangles, a linear handle on trim along 90°, and a port at each end of the line; netlist() is the .cnl.
  6. reply() answers update, event and action alike: every view, a one-line summary, and enabled greying Place when the trim makes the stub negative.
  7. simulate() is the button: simulate on the staged circuit.cnl, whose S11 is drawn beside the closed form's.
  8. commit stages stub.cnl, stub.csch (the same circuit drawn: schematic() lays the parts out on the grid) and stub.cem, and answers placed with "layout": "artwork" — so the cell gets the artwork as layout/artwork.clay and the .cem naming it, and opens ready for an EM run.

Writing a plugin in C# (or any compiled language)

Nothing about a plugin is Python-specific. A compiled program needs the frame, the messages on this page, and a run entry per platform. tools/plugin-examples/csharp-window/ is a complete one: a C# program with its own Avalonia window, published with NativeAOT to one native program per platform, with a headless form. It is the template for an author who already has a .NET desktop tool and wants it to place cells instead of exporting interchange files.

The frame, byte by byte

The answer to hello — {"type":"hello","apiVersion":1}, 31 bytes of JSON, no payload:

00000000: 1f 00 00 00  00 00 00 00  7b 22 74 79 70 65 22 3a   ........{"type":
00000010: 22 68 65 6c  6c 6f 22 2c  22 61 70 69 56 65 72 73   "hello","apiVers
00000020: 69 6f 6e 22  3a 31 7d                               ion":1}

1f 00 00 00 is jsonLen = 31, little-endian; 00 00 00 00 is binLen = 0. A frame with a payload — {"type":"reply","id":2} (23 bytes) carrying the two int64 values 1000 and −500:

00000000: 17 00 00 00  10 00 00 00  7b 22 74 79 70 65 22 3a   jsonLen 23, binLen 16
00000010: 22 72 65 70  6c 79 22 2c  22 69 64 22 3a 32 7d e8   ..."id":2}  then 1000 = e8 03 00 00 00 00 00 00
00000020: 03 00 00 00  00 00 00 0c  fe ff ff ff ff ff ff      -500 = 0c fe ff ff ff ff ff ff

The JSON is UTF-8; non-ASCII text such as Ω may be written as UTF-8 or escaped (Ω).

run, per platform

A compiled plugin ships one program per runtime identifier it supports, each in its own folder, and names them in run:

"run": {
  "win-x64":   { "exe": "bin/win-x64/line-section.exe" },
  "win-arm64": { "exe": "bin/win-arm64/line-section.exe" },
  "osx-arm64": { "exe": "bin/osx-arm64/line-section" },
  "osx-x64":   { "exe": "bin/osx-x64/line-section" },
  "linux-x64": { "exe": "bin/linux-x64/line-section" }
}

A platform you leave out lists the plugin dimmed there, saying why. A native library your program loads (a UI toolkit's, say) sits beside it in the same folder.

window or panel?

A panel is the default choice even in C#: circuitRF draws it, it looks like the rest of the application, and it runs headless for free. Your program is then a loop that answers update and commit. Choose window when the tool already has a window you want to keep, or needs interaction the panel vocabulary cannot express (a live tuner, a drawing editor, an instrument front panel). A window plugin should also have a headless form (above), or it cannot run from the command line or an agent.

NativeAOT's two traps

NativeAOT compiles your program to one native executable with no .NET runtime to install, which is what a plugin wants. Two things go wrong silently:

  1. Reflection-based JSON is trimmed away. JsonSerializer.Serialize(obj) without type information compiles, runs under dotnet run, and throws at run time in the published program — on the first message. Use System.Text.Json source generation: declare your message types on a JsonSerializerContext and pass its type info to every call. (Reading with JsonDocument and writing with Utf8JsonWriter is AOT-safe too, and simpler for passing an object such as the substrate straight through.) Set IsAotCompatible in the project so the build warns about anything else that would be trimmed.
  2. NativeAOT cannot cross operating systems. It links with the platform's own native toolchain (Xcode's command-line tools on macOS; clang on Linux; Visual Studio's C++ workload on Windows), so each operating system's programs are published on that operating system. Cross-architecture on the same OS (arm64 → x64 on macOS) works. A run entry with no program behind it fails for whoever installs it, not for you: publish every RID you list.

A walk through csharp-window/

It makes a microstrip line section of a given impedance and electrical length. Every file stands on its own:

File What it holds
Frame.cs The frame codec, BCL only, written to be copied into another program unchanged: little-endian header and payload, partial reads looped, a clean end of stream between frames returned as null, and a stream ending inside a frame an error
Protocol.cs The messages as classes on a source-generated JsonSerializerContext (PluginJson); ReadHello; one background reader thread; AskAsync, which sends a message with an id and completes when circuitRF's answer with that id arrives; ResolveAsync and LineModelAsync
LineSection.cs The deterministic part: the parameters and circuitRF's line model in, the three documents out. A secant search on ln(width) finds the impedance, the width is rounded to a whole DBU, and length = (degrees/360) · c / (f · √εeff). Both the window and the headless form call it, so they stage identical bytes
MainWindow.cs An Avalonia window built in code (no XAML): a conductor picker, frequency, impedance and electrical length, Place in workspace (placed), Open in circuitRF (open for the placed .clay) and a status line. Every message is awaited, so the UI thread never waits on the pipe
Program.cs Main keeps stdout for frames and points Console.Out at stderr before anything else runs (the C# version of the helper's print redirect), reads hello, and opens the window or runs the headless form by mode
plugin.json kind: window, the parameters of the headless form (including substrate) and its readouts, and a run entry per RID
publish.sh, publish.ps1 One dotnet publish -c Release -r <rid> -p:PublishAot=true -o bin/<rid> per RID; each refuses a RID of another operating system rather than skipping it
LineSection.csproj net10.0, IsAotCompatible, InvariantGlobalization, and Avalonia. It references nothing in the circuitRF repository; empty Directory.Build.props/.targets beside it stop MSBuild reading the repository's own

The window asks resolve (no name) for the workspace's default technology and offers its signal conductors. As the user types, it asks lineModel for each width the search tries. Place in workspace stages line.clay (the rectangle, and a port label at each end facing into the line), line.cnl (an ideal line of circuitRF's modelled impedance and εeff between two ports) and line.cem (naming line.clay, both ports at the line's impedance), then sends placed. Headless, the same code answers update with the width, length, εeff and modelled impedance as readouts, and commit with placed.

To make it yours: rename the project, AssemblyName, id and the run paths; replace LineSection.cs with your tool's computation (keep it a function of its inputs); replace MainWindow.cs's controls with your own window; keep Frame.cs and the message classes as they are, adding a class to PluginJson for each new message you send.

The rules circuitRF enforces

A plugin that crashes, hangs, writes garbage or never answers leaves circuitRF responsive; the panel says what happened and shows your stderr.

Testing a plugin

The command line cannot grant permission: allow the plugin once in the window, and plugin run uses that permission.

Getting a plugin on the list

circuitRF's Settings ▸ Plugins lists the plugins in plugins/index.json in the circuitRF repository. A version is vetted, not a plugin: each release goes on the list by its own pull request.

  1. Release it from your repository with a tag (v0.3.0) and a zip asset holding the plugin folder — plugin.json at the top of the zip, or in one folder at its top. Leave out your build folders (bin/, obj/ of a project kept inside the plugin folder): ship only what run names and what it loads. One zip for every platform is named in the list as any; or one zip per runtime identifier. Build each zip with a tool that keeps Unix permissions (zip -r on macOS or Linux), or a native program arrives without its execute bit and cannot start.
  2. Take its SHA-256: shasum -a 256 plugin-0.3.0.zip (macOS, Linux) or Get-FileHash plugin-0.3.0.zip (Windows).
  3. Open a pull request to circuitRF adding your version to plugins/index.json:
{
  "id": "yourname.coupled-filter",
  "name": "Coupled-Line Filter",
  "summary": "Edge-coupled bandpass filters on a microstrip substrate.",
  "repo": "https://github.com/yourname/crf-coupled-filter",
  "versions": [
    { "version": "0.3.0", "tag": "v0.3.0", "minCircuitRF": "1.8.0", "apiVersion": 1,
      "assets": { "any": { "url": "https://github.com/yourname/crf-coupled-filter/releases/download/v0.3.0/plugin-0.3.0.zip",
                           "sha256": "…" } },
      "reviewed": "" }
  ]
}

Asset URLs must be on github.com. circuitRF checks the download against sha256 before unpacking anything.

What the review checks:

Being on the list means that version was reviewed. It is not an endorsement of the author or of later versions.

Plugin, PCell, or pull request?

If the tool… It is a…
is one parameterised layout cell that should stay live in a layout PCell (PCells)
is a device model external device or Verilog-A (Verilog-A)
synthesises, calculates, imports or reports, and its author wants to own it plugin
is broadly useful, maintained, and fits circuitRF's standards pull request, often after it proved itself as a plugin