The agent surface
Version: 0.1.0-draft Status: design. The tool surface below is specified; the browser API it binds to is not settled, and this document says so where it matters.
JigDAW exposes its operations to an agent running in the browser, through WebMCP. The tools are the same operations the editor uses, through the same dispatcher, per contract section 9.3. There is no second implementation and there are no agent-only operations.
What is settled and what is not
The tool surface is a design decision this project makes, and it is specified here.
The binding is not. WebMCP is an emerging proposal for how a page declares tools to a user agent, and the shape of that API is still moving. This document therefore specifies tool names, arguments and semantics, and deliberately does not specify how they are registered. When the API settles, one adapter module binds these tools to it, and nothing else in the project changes. Treat any code that reaches past that adapter as a defect.
Everything below is also reachable without WebMCP, because the tools are the dispatcher's operations. transmission's MCP server exposes the same kind of surface for the native host, and Jiggy has a local bridge that exposes this one over ordinary MCP.
The local bridge
An MCP client on the same machine can drive an open Jiggy page through npm run mcp-bridge. The session lives in the browser tab, with its audio, and a tab cannot accept a
connection, so the bridge (bin/mcp-bridge.js, src/mcp/BridgeServer.js) holds a Streamable
HTTP endpoint at http://127.0.0.1:8749/mcp and the page connects out to it
(src/mcp/BridgeClient.js). Each tool call goes down to the tab over a server-sent event
stream, runs through the same surface WebMCP and the console use, and its result comes back by
POST. The bridge lists its tools from src/mcp/tools.js and implements none of them.
npm run mcp-bridge # prints a token
claude mcp add --transport http jiggy http://127.0.0.1:8749/mcpPaste the token into the page's Agent bridge section and press Connect. The page writes each call an agent makes to its log.
It is local by construction, and each of these is checked by tests/mcp/BridgeServer.test.js:
- It listens on loopback only, and refuses a request whose
Hostis not loopback, which is what stops a web page reaching it by DNS rebinding. - It refuses a browser request from any page not itself on loopback, on both endpoints.
- A tab must present the token the bridge printed when it started. Other pages on the machine can reach loopback; they cannot know the token. A person pastes it, which is the pairing.
- One tab at a time: a tab presenting the token replaces the one before, as a reload does.
- A call waits
bridgeCallTimeoutMsfromweb/host.jsonfor the page, then fails and says so. With no page connected, a call fails at once, saying how to connect one.
It is a development tool and is never deployed: it needs the MCP SDK, a dev dependency, and the
deployment runs no npm install. bridgePort in web/host.json is read by both ends, so they
cannot disagree about where the bridge is.
Search is demand-driven
The catalogue holds hundreds of plugins and an agent cannot read them all. So discovery is two steps, following transmission's design:
plugins_searchreturns candidates matched on role and signal semantics, with just enough to choose between them.plugin_describereturns everything about one plugin, and is called only for the few that matter.
The reason is that an agent picking a reverb does not need the parameter list of every delay. Returning it anyway spends the context that the agent needs for the actual task.
plugin_validate_chain answers the question that search does not: given an ordered list of
plugins, do adjacent ones agree? That is a type check of trn:produces against
trn:accepts, and it reports the curated pairings from trn:recommendedBefore and
trn:recommendedAfter along with any trn:caution.
Resources
| URI | Content |
|---|---|
jigdaw://project |
The current project as JSON |
jigdaw://project/turtle |
The same project as Turtle |
jigdaw://plugins |
The catalogue, merged curated and discovered |
jigdaw://plugins/profiles |
Curated profiles, as Turtle |
jigdaw://plugins/inspections |
Discovered evidence, as Turtle, including load failures |
jigdaw://diagnostics |
Engine state, transport, buffer health, recent errors |
A resource is a read. A read never changes anything and never needs a revision.
Tools
Discovery
| Tool | Arguments | Returns |
|---|---|---|
plugins_search |
role?, accepts?, produces?, requires?, text?, limit? |
Candidate IRIs with name, role, signals |
plugin_describe |
iri |
The full profile, plus any inspection |
plugin_validate_chain |
iris[] |
Per-adjacency verdict, pairings, cautions |
collection_open |
iri |
Every listed plugin, checked, with per-member notes and any load failure |
plugin_load |
iri, track? |
Fetches, validates and instantiates. Returns the node id and its track id, or a located failure |
plugin_load is the one tool that reaches the network. It performs contract section 3 in
order and reports which step failed, so an agent gets "no CORS header on the processor"
rather than "could not load". Without track the plugin gets a new track of its own, named
after it, and a plugin that accepts MIDI becomes that new track's MIDI input.
collection_open is plugin-collections.md section 3, the same read
the page's own "Open a collection" form performs: it reaches the network to fetch the
collection document and check each member's profile and capabilities, but fetches no module,
processor or asset, and changes nothing. It is a catalogue read beside plugins_search, not
an Op, for the same reason: an agent choosing between plugins should not need to load one
first to find out whether it will run here. Load a chosen member afterwards with
plugin_load.
The graph
| Tool | Arguments | Returns |
|---|---|---|
graph_apply_changes |
changes[], expectedRevision, dryRun? |
New revision, or a conflict |
node_add |
pluginIri, position? |
Node id |
node_remove |
nodeId |
|
connection_add |
from, to, fromPort, toPort, kind, lowNote?, highNote? |
|
connection_set |
connectionId, lowNote?, highNote? |
A MIDI connection only |
connection_remove |
from, to, fromPort, toPort |
graph_apply_changes is the primitive and the rest are conveniences over it. Every
changeset is atomic: all of it applies or none does.
Optimistic concurrency. A changeset carries expectedRevision, the revision the caller
last observed, and is rejected if the project has moved on. This is transmission's design
and it exists because an agent and a person editing the same project is the normal case
here, not an exotic one. A rejected changeset returns the current revision and what changed,
so the caller can retry against it.
dryRun validates a changeset and reports what it would do without committing. An agent
building a signal chain should use it before applying, because a graph that fails validation
halfway is worse than one that was never touched.
A composite plugin (nested-plugins.md) needs no tool of its own. plugin_load and node_add take its IRI, and it is one node:
its parameters are the ones it exposes, so parameter_set and the envelope tools address them by those symbols, and node_bypass acts on it as
a whole. node_unpack turns it back into its members as ordinary nodes, and composite_pack goes the other way, describing a selection as a composite profile to publish and changing nothing. Its members are not nodes and have no ids an agent can use. diagnostics reports the compiled graph, whose order and
compensation name the members of a composite by flat ids, which are opaque keys and not something to parse.
Tracks
| Tool | Arguments | Returns |
|---|---|---|
track_add |
label? |
Track id |
track_remove |
trackId, moveNodesTo? |
|
track_layout |
trackId, move?, color?, laneSize? |
The track's layout and place. Editor metadata: no revision, no undo |
track_set |
trackId, label?, midiInput?, audioInput?, output? |
|
track_set_channel |
trackId, gain?, pan?, muted?, soloed? |
The whole strip, as applied |
node_move_to_track |
nodeId, trackId |
Master, sends, markers and regions
| Tool | Arguments | Returns |
|---|---|---|
master_set |
gain?, pan?, muted?, audioInput? |
|
send_add |
from, to, kind?, level?, tap?, lowNote?, highNote? |
Send id |
send_set |
sendId, level?, tap?, lowNote?, highNote? |
Level and tap on an audio send, the range on a MIDI one |
send_remove |
sendId |
|
marker_add |
atBeat, label? |
Marker id |
marker_set |
markerId, atBeat?, label? |
|
marker_remove |
markerId |
|
region_add |
startBeat, lengthBeats, label? |
Region id |
region_set |
regionId, startBeat?, lengthBeats?, label? |
|
region_remove |
regionId |
Each is one Op through the dispatcher, so each is one undo step and takes expectedRevision. A send is
pre or post fader and its level is linear. With kind: "midi" a send routes the MIDI the source track makes to the plugin
the target's MIDI clips play into (its midiInput), and takes no level or tap, and may carry lowNote and highNote, so that only the notes from one to the other go. The same range is on a MIDI
connection (connection_add, connection_set). Two routes from one source with ranges that meet split a keyboard between two instruments,
and a note-off follows its note-on, so changing a range mid-note leaves nothing sounding. A target with no midiInput receives nothing. track_set with output sends a track to another track
instead of the master, which makes the target a bus; a cycle is refused, for sends and outputs alike.
What a send or a bus output delivers arrives at the target track's audioInput node when it has one, so a
plugin on a bus processes it, and at the target's fader when it has not. The master is the same: plugins go
on it with plugin_load or node_move_to_track and the track master, and master_set with audioInput
names the one the mix of every track arrives at. A node with no audio input is refused as an audioInput.
Clips
| Tool | Arguments | Returns |
|---|---|---|
envelope_add |
nodeId, symbol, points[] |
Envelope id |
envelope_set |
envelopeId, points[] |
|
envelope_remove |
envelopeId |
|
node_move_in_chain |
nodeId, delta (-1 or 1) |
|
node_bypass |
nodeId, bypassed |
|
node_unpack |
nodeId |
Ids of the members |
composite_pack |
nodeIds[], iri, label, comment?, expose? |
The profile as Turtle, a summary, warnings |
clip_add |
trackId, startBeat, lengthBeats, notes? |
Clip id |
clip_add_audio |
trackId, source, startBeat, lengthBeats, offsetSeconds? |
Clip id |
clip_set_notes |
clipId, notes[] |
|
clip_move |
clipId, startBeat?, lengthBeats?, trackId? |
|
clip_set |
clipId, muted?, locked?, fadeInBeats?, fadeOutBeats?, color? |
Revision and the clip |
clip_split |
clipId, atBeat |
Both clip ids |
clip_duplicate |
clipId, startBeat?, trackId? |
Clip id |
clip_remove |
clipId |
A note is { startBeat, lengthBeats, pitch, velocity }, its start counted from the clip's.
clip_set_notes replaces every note at once, so a phrase is one edit and one undo. A MIDI clip
plays into its track's MIDI input and into nothing on a track with none. An audio clip names its
file by absolute IRI and plays into the track's audio input, or straight to its fader.
A track is a mixer strip and a line of the arrangement, per
project-format.md "Tracks". Every plugin is on exactly one, and whatever
a track's chain produces reaches its fader before the master. track_remove is refused while
plugins are on the track unless moveNodesTo says where they go, so removing a track never
removes plugins as a side effect. track_set names the node a track's MIDI clips play into, and the node its audio clips, sends and bus
outputs arrive at, and refuses one that is not on that track.
Parameters and transport
| Tool | Arguments | Returns |
|---|---|---|
parameter_set |
nodeId, symbol, value, atFrame? |
|
parameters_set_batch |
settings[] |
|
parameter_reset |
nodeId, symbol |
value, the default now held |
history_undo |
revision, canUndo, canRedo |
|
history_redo |
revision, canUndo, canRedo |
|
script_run |
source, dryRun?, now? |
swapped, or the plan for a dry run, or the errors with their lines |
transport_play |
fromBeat? |
Starts there; without it, at the loop's start when a loop is on, else at the beginning |
transport_stop |
||
transport_configure |
tempo?, timeSignature?, loop?, position? |
script_run runs a Reel script, livecoding.md. It is the same operations the other tools
perform, checked as a whole first: a script with an error changes nothing and the errors come back with their line
numbers, and dryRun returns what the script would do without doing it.
Parameters are addressed by lv2:symbol, never by index. An index is a property of a
build; a symbol is a property of the plugin, and contract section 5.1 requires it to be
stable across versions.
parameters_set_batch exists because setting twenty parameters as twenty calls is twenty
round trips and twenty revisions.
Project and diagnostics
| Tool | Arguments | Returns |
|---|---|---|
project_get |
The project and its revision | |
project_new |
||
project_open |
source |
|
project_save |
||
status |
Revision, track, node and clip counts, transport, whether anything is failing | |
diagnostics |
Engine detail, including the last error per node |
status is deliberately small and cheap. An agent should call it before making changes,
which is transmission's instruction to its own clients and the reason its server states it
in the server description rather than hoping.
Errors are located
A tool failure names what failed, where, and what would fix it. A validation failure carries the focus node and the constraint. A load failure carries the step from contract section 3. A conflict carries both revisions.
An agent cannot inspect a stack trace or read a log. The error message is the entire interface to the failure, so a message that says only that something went wrong has failed twice.
What agents must not be able to do
The tool surface is not a general capability. An agent using it MUST NOT be able to:
- reach a plugin's user interface frame, or any origin, directly;
- bypass profile validation or integrity verification when loading a plugin;
- read or write host storage or credentials;
- construct an audio node other than through
plugin_load.
Those are not omissions to be filled in later. A tool that offered any of them would undo the isolation that contract sections 3.2, 9.1 and 11 exist to create, and an agent acting on a profile fetched from an arbitrary origin is exactly the situation they were written for.
Open
- Whether a changeset can span a plugin load. Loading reaches the network and can fail slowly, which does not sit well inside an atomic operation. Probably load first, then apply, with the node id as the join.
- Whether
jigdaw://pluginsshould federate to the catalogue live or serve only what the local store holds. Seedocs/architecture.md.
A worked example
examples/songs/glass-hours.js is a 40 bar piece for six instrument tracks, a reverb return and a glue
compressor on the master, made through these tools: plugin_load for each instrument and effect, track_set to say what plays into what,
clip_add for the music, send_add and master_set for the routing, envelope_add for the movement, and marker_add and region_add for the
sections. It was then balanced by playing it and reading the meters, and the script writes the same file the page held. It is the quickest way to
see what a whole session looks like in these operations.