Agent protocols
JSON-RPC 2.0, the MCP server and the script API: the same commands for every client.
Status: 0.5.0 · issues #100 (JSON-RPC), #101 (MCP), #102 (script API), #108 (parity) · companion to docs/designer-cli.md §3. Code: src/agent/rpc, src/agent/mcp, src/agent/core.ts, src/api/edsnx.ts.
All three protocols run the same registry commands (src/commands, createRegistry()) on one Session
through AgentCore (src/agent/core.ts): the same edits, undo steps, validation and error codes as the edsnx
CLI and the editor. Calls of one server are serialised, so edits from several clients never interleave.
Units are millimetres and degrees, KiCad axes (x right, y down); every document command takes path,
relative to the server’s working directory.
1. JSON-RPC 2.0 (edsnx serve)#
edsnx serve --stdio # one client on stdin/stdout (default)
edsnx serve --socket /tmp/edsnx.sock # Unix socket (named pipe on Windows), many clients, one session
edsnx serve --open board.kicad_pcb --attach # open documents at start; --attach edits the open VS Code editor
node out/cli.js serve --stdio # without the bin on PATH1.1 Framing#
LSP framing: Content-Length: <bytes>\r\n\r\n<UTF-8 JSON>. A client whose first byte is { or [ is served
newline-delimited JSON instead (handy from a shell: echo '{"jsonrpc":"2.0","id":1,"method":"session.documents"}' | edsnx serve).
stdout carries only protocol frames; diagnostics go to stderr. The socket file is created with mode 0600; a
stale socket is removed, a live one refuses the start.
1.2 Methods#
- Every registry command id is a method; params are its args object (by name), the result is the command
result:
pcb.addTrack,sch.placeSymbol,query.items,check.drc,project.updatePcb,render.png, … (docs/commands.md, orrpc.discover). - Session methods (
RPC_SESSION_METHODS):session.open,session.close,session.documents,session.undo,session.redo,session.save,session.revert,session.describe(withpathit also returnsquery.describeof that document),session.batch({ steps: [{ id, args }], label? }, one undo group, rolled back on the first failure) andsession.transaction, which takes either rawintentsfor one document orstepsof registry commands: all-or-nothing, one undo step per document. rpc.discover→{ methods, notifications, extra }.$/cancel{ id }(notification) cancels one of the caller’s requests: a queued request fails with −32800 without running; a running command sees itsAbortSignal.- Edits stay in memory until
session.save(unless--auto-save).
1.3 Batches and notifications#
A JSON array of requests is a JSON-RPC batch: the reply is an array of the responses (notifications get none);
each element succeeds or fails on its own. For all-or-nothing use session.transaction.
Server notifications:
| method | to | params |
|---|---|---|
document.changed | every client | SessionChange without the intent, plus op ({ path, reason: edit/undo/redo/save/revert/open/close, revision, hash, label, newIds, actor, at }) |
command.progress | the caller | { id, method, message?, percent? } |
session.log | every client | { level, entry: LogEntry } after each non-query command, or { level, message, method, id } diagnostics |
1.4 Errors#
Command failures keep their CommandError code in error.data.code:
| CommandError | JSON-RPC code |
|---|---|
unknown-command | −32601 (method not found) |
invalid-args | −32602 (invalid params; data.errors lists the schema errors) |
cancelled | −32800 |
internal | −32603 |
ui-only / no-document / wrong-kind / stale-revision / check-failed / service-unavailable / not-found | −32001 … −32007 |
1.5 Example#
→ {"jsonrpc":"2.0","id":1,"method":"pcb.addTrack","params":{"path":"blinky.kicad_pcb","layer":"F.Cu","net":"GND","width":0.25,"pts":[[110,110],[120,110]]}}
← {"jsonrpc":"2.0","method":"document.changed","params":{"path":"/…/blinky.kicad_pcb","reason":"edit","revision":1,"op":"addTrack","newIds":["…"],…}}
← {"jsonrpc":"2.0","id":1,"result":{"newIds":["…"],"revision":1,"hash":"…","label":"Add track"}}
→ {"jsonrpc":"2.0","id":2,"method":"session.transaction","params":{"label":"GND","steps":[{"id":"pcb.route","args":{…}},{"id":"pcb.addVia","args":{…}}]}}
→ {"jsonrpc":"2.0","id":3,"method":"session.save","params":{}}2. MCP server (edsnx mcp)#
Model Context Protocol over stdio (newline-delimited JSON-RPC), implemented by hand against the 2025-06-18 specification; 2025-03-26 and 2024-11-05 clients are answered in their version.
Register it with Claude Code:
claude mcp add edsnx -- edsnx mcp # or: claude mcp add edsnx -- node /path/to/out/cli.js mcp
claude mcp add edsnx-live -- edsnx mcp --attach # edit the documents open in VS Codeor commit a project .mcp.json like examples/mcp.json ({ "mcpServers": { "edsnx": { "command": "edsnx", "args": ["mcp"] } } }).
Claude Desktop takes the same entry in claude_desktop_config.json. The electronics-designer skill
(.claude/skills/electronics-designer/SKILL.md) tells Claude how to use the tools.
- Tools: every registry command except
kind: "ui"ones (dialogs, view switches) is a tool named with_for.(pcb.addTrack→pcb_addTrack,check.drc→check_drc), with the command’s JSON schema asinputSchema, its description, kind/scope, units and up to three examples in the description, andreadOnlyHinton queries. (2.1.0 AUT-6) From protocol 2025-06-18 on, every tool also has anoutputSchema(tools/listleaves it out for older clients): the command’s result schema when it is an object, a schema of{ value }wrapping it otherwise (arrays, unions), the generic{ "type": "object" }for the few commands without one (session tools,dfm.profiles.get,pcb.autoroute.cancel/providers,sim.models.set,lib.footprint.wizardand the managed-library commands). Results: the JSON result as text plusstructuredContent(the result object, or{ value }as the schema says), which validates against theoutputSchema;render.pngreturns an image. Failures are results withisError: trueand"<code>: <message>"so the model can correct itself; an unknown tool name is a −32602 error. Progress:notifications/progresswhen the call has_meta.progressToken;notifications/cancelledaborts a call. - Resources (
MCP_RESOURCES,<path>percent-encoded):kicad://board/<path>/items(query.items),/nets(query.nets),/drc(check.drc),/render.png(render.png, a base64 PNG blob; a resource error “render not available” when the runtime has no renderer),kicad://schematic/<path>/sheets(query.sheetTree) and/erc(check.erc).resources/listlists them for every open document,resources/templates/listgives the URI templates,resources/subscribesendsnotifications/resources/updatedafter each change of that document, and opening/closing a document sendsnotifications/resources/list_changed. - Prompts:
design-workflow(goal?,project?) — the schematic → netlist → board → place → route → zones → DRC → fab procedure with the tool names (src/agent/mcp/prompts.ts). - Also
ping,logging/setLevel(notifications/message),completion/complete(empty). - (2.1.0 AUT-6) A headless
edsnx mcp(andedsnx serve) appends every call to the project’s.edsnx/session.jsonl, the log of theedsnxCLI (attached sessions are logged by the editor into the same file);session_exportturns it into a Markdown report or one JSON document.project_newcreates a project (the registry’sproject.new, built on a private session: no half-built board is ever visible),query_pairs/query_groupsreport differential pairs and matched-length groups; theedsnx --checkflag now includes the library rules. test/agent.blinky.mcp.test.ts rebuilds the blinky sample with ~140 scripted tool calls and checks its netlist, DRC and ERC with kicad-cli.
2.1 AI chat box (edsnx mcp --attach)#
The editor’s AI chat box (docs/user-guide.md, AI chat box) talks to agents over the live editor attachment (src/agent/attachProtocol.ts). Two methods and one notification are added to it:
| Message | Direction | Params → result |
|---|---|---|
agent.chatSubscribe | client → editor | { listen?: boolean = true, chatId?: string } → AgentChatRequest[]: the requests no agent took yet (or the one chatId); with listen the connection also receives later ones |
agent.chat | editor → client (notification) | AgentChatRequest { chatId, text, path?, uri?, at }: a request typed in the box, sent to the listening connections |
agent.chatEvent | client → editor (request or notification) | AgentChatEvent { chatId, kind, text?, percent?, intents?, label?, path? } → { ok: true, revision? } |
kind is text (a delta appended to the answer), progress (the progress line, percent 0…100),
intents (EditIntents applied on path, default the request’s document, as one undoable change that goes
to the review list, named label), done (optional final text) or error (text is the message). Bad
params are −32602 errors (parseChatEvent). Every agent edit made while a request runs, through any tool, is
counted on that request and listed on its proposal card.
When the MCP server is attached, it subscribes and offers:
agent_chat{ wait?: seconds }: the pending requests as JSON ([{ chatId, text, path, at }]), waiting up towait(max 600) for one when none is pending.agent_reply{ chatId, progress?, percent?, intents?, label?, path?, text?, done?, error? }: sendsprogress, thenintents,text, anddone/error, each as oneagent.chatEvent.- Prompt
editor-chat: the pending requests and how to answer them (/mcp__edsnx__editor-chatin Claude Code).
With no listening connection, the editor starts softforge.edsnx.agent.chatCommand (default
claude --print --mcp-config ${mcpConfig} --allowedTools mcp__edsnx) with the request on stdin; the generated
config runs edsnx mcp --attach with EDSNX_CHAT_ID set, so that server takes only this request
(agent.chatSubscribe { listen: false, chatId }) and the process’s stdout becomes the answer.
3. Script API (edsnx run, @softforge/edsnx-api)#
edsnx run build.ts --board blinky.kicad_pcb [--schematic blinky.kicad_sch] [--no-save] [--json] [-- args]The script is TypeScript or JavaScript (ESM, top-level await). import edsnx, { board, sch, project, lib, query } from '@softforge/edsnx-api' binds to the live API (the same names are globals); relative imports are bundled
with esbuild when it is installed. (2.1.0 AUT-6) Without esbuild, compileScriptTree (src/api/edsnx.ts) resolves
them itself: the script and every module it imports with a relative specifier (./x, ./x.js finding x.ts,
folders finding index.ts), transitively, are stripped of their types by Node (module.stripTypeScriptTypes) and
written as .mjs files to a temporary folder next to the script that mirrors the source tree, with the specifiers
rewritten; each module stays its own ES module (cycles behave as in Node), bare packages resolve from the script’s
folder, and the folder is removed afterwards. This was chosen over bundling esbuild-wasm (about 10 MB in the
extension) because Node already strips types. A default-exported
function is called with the API; the default export (or the function’s return value) is printed. The session
is saved when the script ends unless it throws or --no-save is given.
| API | command |
|---|---|
board.place({ lib, name, at, rot?, side?, ref?, value?, padNets? }) | pcb.placeFootprint |
board.route({ pts, layer?, net?, width?, vias? }) | pcb.route |
board.via, board.zone({ pts, net, layers?, fill? }), board.fillZones(), board.outline({ from, to }) | pcb.addVia, pcb.addZone, pcb.fillZones, pcb.addGraphic |
board.move/rotate/delete, board.drc(), board.items/nets/unrouted/describe | pcb.move, pcb.rotate, pcb.delete, check.drc, query.items, query.nets, query.unrouted, query.describe |
sch.place({ lib, name, at, footprint? }), sch.wire({ pts }), sch.label({ text, at }), sch.junction({ at }), sch.noConnect({ at }), sch.annotate(), sch.erc(), sch.sheets() | sch.placeSymbol, sch.wire, sch.label, sch.addJunction, sch.addNoConnect, sch.annotate, check.erc, query.sheetTree |
project.new({ name, template? }), project.updatePcb(), project.plot(), project.fabricate() | the edsnx project new builtin, project.updatePcb, project.plot, project.fabricate |
lib.search(query, { kind? }), query.<name>(args) | lib.search, every query.* command |
edsnx.run(id, args), edsnx.open/save/undo, edsnx.transaction(label, fn) | any command, session.open, session.save, session.undo, one undo group rolled back on a throw |
path defaults to board.use(path) / sch.use(path), the --board/--schematic flags, or the only open
document of that kind. Types: src/api/edsnx-api.d.ts, published as packages/edsnx-api (@softforge/edsnx-api;
node scripts/build-edsnx-api.mjs refreshes the copy). In code, createEdsnxApi({ registry, session }) and
runScript(path, session, opts) from src/api/edsnx.ts.
4. Embedding and parity#
src/agent/index.ts exports serveRpc(opts), serveMcp(opts), createAgentRuntime(opts), RpcServer,
McpServer, AgentCore and the script API; src/agent/run.ts exports runAgentCommand(argv, io, cwd) and
AGENT_SUBCOMMANDS for the CLI’s serve, mcp and run. The parity tests (test/agent.rpc.test.ts,
test/agent.mcp.test.ts) check that every registry command is an RPC method and an MCP tool, except the
UI-only ones, which uiOnly(registry) lists; test/agent.skill.test.ts checks that this page and the skill
name only existing commands.