Spec editing API¶
The canvas changes a spec through one endpoint per mode. The request is a list of operations. They are applied in order to one in-memory copy of the spec and validated once at the end; if any operation fails, nothing is written. Design and reasons: ADR-0055.
| Mode | Endpoint |
|---|---|
Local (kampong dev) |
PUT /api/spec |
Hosted (packages/server) |
PUT /api/specs/:id (session cookie, workspace) |
// request
{
"ops": [ /* operations, below */ ],
"baseRevision": "a1b2c3d4" // optional: refuse the write if the spec has changed since
}
Loading a spec that does not exist yet (local)¶
GET /api/spec answers 200 with { "success": false, "missing": true, "errors": [], "source": "" }
when the spec file does not exist: a new workflow, not an error. POST /api/spec/blank then writes
the minimal valid spec (with the schema pragma) and answers 409 if the file already has content.
PUT /api/spec/source also creates a missing file, which is how a template choice is saved. A file
that exists but cannot be read is still a 500.
The template gallery (KAM-216) is local only: GET /api/templates lists the templates
(name, title, description, stepKinds, runsOffline), and POST /api/spec/template with
{ "name": "market-etl" } saves that template's spec as the workflow and copies its recorded fixtures to
.kampong/fixtures/. It answers 404 for an unknown name and 409 if the spec already has content, unless
the body also has "replace": true. The hosted server has no template routes yet, so the canvas hides the
gallery there. Hosted specs always exist, so they have no such state.
Operations¶
set, add and remove edit a field at an index path (["agent", "workflow", 2, "action"]) and
are the way to change a step's own fields. The step and tool operations address by name, so
they keep working when something above them changes, and they keep references valid.
| Operation | Effect |
|---|---|
{ op: "step.insert", place, step } |
Insert a step object at a place. |
{ op: "step.remove", step } |
Remove a step and everything inside it. |
{ op: "step.move", step, to: place } |
Move a step (with its body or arms) to another place. |
{ op: "step.rename", step, to } |
Rename a step and rewrite every read of it. |
{ op: "tool.remove", tool } |
Remove a tool. |
{ op: "tool.rename", tool, to } |
Rename a tool and rewrite every use of it. |
A place is a list and an index in it (the index defaults to the end; for a move it is the final position):
{ "in": "workflow", "index": 0 }
{ "in": { "step": "each_alert", "slot": "steps" } } // a foreach body
{ "in": { "step": "route", "slot": "then" } } // a branch: then | else
{ "in": { "step": "route", "slot": "cases", "key": "urgent" } } // a switch case
{ "in": { "step": "route", "slot": "default" } }
Inserting into a branch else or a switch default that does not exist yet creates it.
Rules that apply to every operation:
- A step name is used once per workflow, branches and foreach bodies included. In a
"1.1"spec a name cannot betrigger,inputorvars, or a foreach'sas. In a"1.0"spec a new name can use only letters, digits and_. - A foreach body cannot hold an approval step, a tool that requires approval, or another foreach.
- A
"1.1"step kind (transform,validate,dedupe,foreach,branch,stop) cannot go into a"1.0"spec: upgrade it first. - Rename rewrites each read of the step:
{{ name.field }}and JSONata expressions in"1.1",{name.field}and the conditionname.field > 3in"1.0", and{name.field}in a tool'surl,bodyand the like. A name inside a string literal or another object's field is not a read and is left alone. The layout entry moves with it. - Remove is refused while anything outside the removed steps still reads it (
has_references). Remove the readers in the same request and it goes through. - Move is refused when it would put a step before one it reads, or out of the branch arm or
loop that can see it (
breaks_references). When top-level steps change order, their nodes swap vertical positions in the layout so the canvas column reads in run order. The canvas offers this as Up and Down on each step row, moving one place within the step's own list.
Responses¶
// 200
{
"success": true,
"spec": { /* the validated spec */ },
"revision": "a1b2c3d5",
"changes": {
"renamed": [{ "kind": "step", "from": "fetch", "to": "get_price" }],
"removed": [],
"rewrote": 3 // reads rewritten
}
}
// 422: refused, nothing written
{
"success": false,
"revision": "a1b2c3d4",
"errors": [{
"code": "has_references",
"opIndex": 0,
"message": "step \"fetch\" is read in 3 places: edit or remove them first",
"path": ["agent", "workflow", 1, "assert"], "line": 8,
"references": [{ "path": ["agent", "workflow", 1, "assert"], "kind": "expression",
"text": "fetch.price > 0", "line": 8 }]
}]
}
// 409: baseRevision is out of date, nothing written
{ "success": false, "revision": "a1b2c3d5", "errors": [{ "code": "stale", "message": "..." }] }
errors is the same list as for a plain patch (path, message, line, column), with optional
code, opIndex (which operation of the request) and references. An error without a code is
the validator's, about the spec as it would be after all the operations. Line numbers refer to the
file as it is now, not as it would be after the change.
Codes: step_not_found, tool_not_found, slot_not_found, ambiguous_step, name_taken,
invalid_name, invalid_step, bad_index, move_into_self, nested_foreach,
approval_in_foreach, needs_version_1_1, has_references, breaks_references, unrewritable,
stale.
revision is a token for the spec's current state. Locally it is a hash of the file text, so an edit
made outside the canvas changes it too. Hosted, it is the spec's version. Send it back as
baseRevision to make a write conditional.
Export and import (YAML)¶
YAML is the interchange format (ADR-0054). The
canvas offers Download as file and Import from file in the header of its YAML view, in both modes. The YAML view is a read-only "For developers" drawer, closed by default; open it with the </> button in the top bar or Alt+Y, and the choice is remembered in the browser. Select a node and its lines are marked in the drawer (and scrolled to when they are off screen); click a line to select its node. This works for steps inside a foreach body or a branch arm, tools, the trigger and guardrails, and never changes the YAML. With the drawer closed, the selection is kept and the marking shows when you open it.
- Export needs no new endpoint: it is the
sourcethatGET /api/spec(local) andGET /api/specs/:id(hosted) already return, which is the stored text as it is. A downloaded file runs underkampong run,kampong serveandkampong export. - Import replaces the whole spec with the text given, if it validates:
| Mode | Endpoint |
|---|---|
| Local | PUT /api/spec/source |
| Hosted | PUT /api/specs/:id/source |
The answers are the same as for operations: 200 with { success, spec, revision }, 422 with
errors (each with a line) and nothing written, 409 for a stale baseRevision, and 400 when
source is missing. The text is stored exactly as given, so import(export(spec)) is the same file
and the same revision. In the hosted product the canvas notes that comments in the file may not be
kept, because hosted storage is not guaranteed to keep them (ADR-0054). The canvas checks a chosen
file with the same validator first and asks for confirmation before replacing a spec that has content.
From TypeScript¶
@kampong/spec exports applyOpsToSource(source, ops) (pure, no I/O), findReferences(source,
{ step } | { tool }), and SpecRepository.applyOpsAndSave(ops, { baseRevision }). The canvas's
ApiClient.applyOps(ops, { baseRevision }) resolves with success: false and the errors for a
422 or 409, and throws only for a transport or server error. applyPatch(ops) is kept and throws on a
refusal.