Skip to content

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 be trigger, input or vars, or a foreach's as. 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 condition name.field > 3 in "1.0", and {name.field} in a tool's url, body and 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 source that GET /api/spec (local) and GET /api/specs/:id (hosted) already return, which is the stored text as it is. A downloaded file runs under kampong run, kampong serve and kampong 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
{ "source": "version: \"1.1\"\nagent: ...", "baseRevision": "a1b2c3d4" }   // baseRevision optional

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.