Skip to content

plan.json schema

A plan.json describes a DAG of agent tasks submitted to the orchestrator via pangolin orch submit. It deserializes into a Run — a set of WorkItems plus their edges, placed on a named queue. The schema below is the Run / WorkItem shape from pangolin-orchestrator.

interface Run {
id: string; // run id (also overridable via `pangolin orch submit --queue` for queue)
queue: string; // named queue this run is placed on
items: WorkItem[]; // the DAG nodes
}

pangolin orch submit --queue <name> overrides the plan’s queue at submit time.

Each entry in items is one dispatchable DAG node:

interface WorkItem {
id: string; // unique within the run
executor: string; // id of the registered Executor that runs this item
inputs: Record<string, unknown>; // forwarded to the executor
depends_on: string[]; // ids of items that must reach `done` before this readies
resourceLocks: string[]; // shared resource keys that serialize contending items
subagentShape?: string; // optional: id of a registered SubagentShape; when set, `inputs` is validated against its inputSchema
needs?: Record<string, InputBinding>; // optional: typed-product handoff wiring, input key -> upstream product
}
interface InputBinding {
from: string; // upstream WorkItem id in the same run
select: OutputSelector; // WHICH product of the upstream
}
type OutputSelector =
| { kind: 'patch' } // the upstream's resultRef (the dev patch artifact)
| { kind: 'output'; path: string }; // a file the upstream wrote to its outputs/ directory
FieldTypeRequiredMeaning
idstringyesItem id, unique within the run. Referenced by other items’ depends_on.
executorstringyesThe registered Executor that runs this item (e.g. dispatch).
inputsobjectyesFree-form inputs forwarded to the executor. For the dispatch executor these include subagent and workerInput.
depends_onstring[]yesIds of items in the same run that must reach done before this item readies. Empty array = no dependencies.
resourceLocksstring[]yesShared resource keys. Items holding overlapping keys serialize; items with disjoint keys fan out in parallel. Empty array = no locks.
subagentShapestringnoWhen set, the item’s inputs are validated against the named SubagentShape’s inputSchema.
needsobjectnoTyped-product handoff wiring: which upstream product feeds which named input of this item. See below.

needs wires an upstream item’s typed product into a named input of this item, by content-addressed reference — the mechanism dependent DAGs use so a downstream task actually builds on an upstream result. Each entry maps an input key to an InputBinding: from names the upstream item, select picks which of its products — { "kind": "patch" } for the upstream’s patch artifact (resultRef), or { "kind": "output", "path": "<file>" } for a file the upstream wrote to its outputs/ directory.

How a binding flows through a run:

  • At submit, every needs[*].from is auto-unioned into depends_on — you cannot wire a need without its dependency, and the engine’s readiness logic is unchanged. pangolin orch validate (and the submit path itself) checks the wiring statically: references exist, the selected product is declared, edge-type tags match, no cycles.
  • At fire time, the engine resolves each binding against the now-done upstream’s recorded products and threads the resulting content-addressed refs to the worker under the reserved inputs.inputRefs carrier key. The submitted inputs snapshot is never mutated.
  • In the worker, each binding’s bytes are fetched, integrity-verified, and materialized at inputs/<key> in the workspace — the agent (or script block) reads them from there. What to do with the bytes (e.g. git apply inputs/patch.diff) is a pack/setup concern, not the seam’s.
  • In the audit trail, the consumed refs are sealed into the item’s dispatch manifest at fire, and pangolin verify’s provenance-closure check proves every consumed ref equals a sealed output product of a verified item in the same run — byte-level provenance with no blob re-fetch.

For the dispatch executor, a handful of inputs keys are reserved carriers rather than free-form worker input: subagent, env, workerInput, inputRefs (engine-written, never authored), and pipeline. Setting inputs.pipeline to a registered pipeline ref pins a declared block-pipeline: the worker fetches the spec by its content hash, re-validates it, and runs that pipeline instead of the default execution steps — see Dispatch lifecycle → The block-pipeline runner and pangolin pipeline.

Pattern payloads: inputs.gate and inputs.mapReduce

Section titled “Pattern payloads: inputs.gate and inputs.mapReduce”

Two further reserved keys are read by the queue’s execution pattern, not by the executor — they are meaningful only when the run’s queue is bound to the matching pattern (a queue without that pattern treats them as inert inputs). The shapes, from pangolin-orchestrator’s pattern contract:

/** On a gate item's reserved `inputs.gate` key — pipeline pattern only. */
interface GateConfig {
onRed: 'advance' | 'spawn-fix';
subject: string; // itemId whose product is being gated
fixTemplate?: SpawnTemplate; // required for spawn-fix to actually spawn
maxFixAttempts?: number; // default 1
}
/** On the splitter's reserved `inputs.mapReduce` key — map-reduce pattern only. */
interface MapReduceConfig {
map: SpawnTemplate & { needsKey?: string; outputPath?: string }; // defaults: 'input', 'result'
reduce: SpawnTemplate & { keyPrefix?: string }; // default: 'part'
}
/** A user-declared template for items the pattern will spawn (subset of WorkItem). */
interface SpawnTemplate {
executor: string;
inputs: Record<string, unknown>;
subagentShape?: string;
resourceLocks?: string[];
}

For what the patterns do with these payloads — spawn timing, deterministic ids, the red-gate lineage — see Execution patterns; for authoring a working plan around them, see Assemble a pattern-driven plan.

This is examples/offload-fanout/plan.json — a four-item fan-out: three independent edits (disjoint locks, run in parallel) followed by a verify that depends on all three.

{
"id": "fanout-1",
"queue": "default",
"items": [
{
"id": "edit-alpha",
"executor": "dispatch",
"inputs": { "subagent": "code-edit", "workerInput": { "file": "alpha.ts" } },
"depends_on": [],
"resourceLocks": ["fixture/alpha.ts"]
},
{
"id": "edit-beta",
"executor": "dispatch",
"inputs": { "subagent": "code-edit", "workerInput": { "file": "beta.ts" } },
"depends_on": [],
"resourceLocks": ["fixture/beta.ts"]
},
{
"id": "edit-shared",
"executor": "dispatch",
"inputs": { "subagent": "code-edit", "workerInput": { "file": "shared.ts" } },
"depends_on": [],
"resourceLocks": ["fixture/shared.ts"]
},
{
"id": "verify",
"executor": "dispatch",
"inputs": { "subagent": "verify" },
"depends_on": ["edit-alpha", "edit-beta", "edit-shared"],
"resourceLocks": []
}
]
}

This is examples/handoff-dag/plan.json — a two-item dependent edit: apply-patch consumes edit-a’s patch artifact via needs, so the second worker materializes the first worker’s diff at inputs/patch before its agent runs. Note apply-patch declares no depends_on — the edge comes entirely from needs and is unioned in at submit.

{
"id": "handoff-dag-1",
"queue": "default",
"items": [
{
"id": "edit-a",
"executor": "dispatch",
"inputs": { "subagent": "code-edit", "workerInput": { "file": "src/main.ts" } },
"depends_on": [],
"resourceLocks": []
},
{
"id": "apply-patch",
"executor": "dispatch",
"inputs": { "subagent": "apply-patch" },
"depends_on": [],
"resourceLocks": [],
"needs": {
"patch": { "from": "edit-a", "select": { "kind": "patch" } }
}
}
]
}

Once submitted, each item carries a mutable status from this closed set: pending, ready, running, done, failed, skipped, cancelled. The terminal subset is done / failed / skipped / cancelled. When an item fails or is cascaded, its persisted state carries a reason string. These are internal run-state fields (ItemState), not part of the submitted plan.

A WorkItem itself does not pin a target, env bundle, or worker image — those bindings live on the executor configured in pangolin.config, not in the plan. For the dispatch executor (DispatchExecutor), the pangolin.config.mjs wires target, workerImage, and secrets; the plan item supplies only inputs.subagent and the per-item workerInput. This keeps the plan portable across environments — the same plan.json runs locally or against Fargate depending solely on the executor wiring.