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, capabilities, addCapabilities, 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.

inputs.capabilities replaces the subagent’s bound capability set for this item; inputs.addCapabilities appends to it. Both take an array of capability names or CapabilityRefs, and both are optional — omit them and the item runs with exactly the set bound at subagent-registration time.

{
"id": "verify",
"executor": "dispatch",
"inputs": {
"subagent": "reviewer",
"capabilities": ["workspace@snapshot-2026-08-01"]
},
"depends_on": ["implement"],
"resourceLocks": []
}

Without this, varying a worker’s workspace substrate means re-registering the subagent — mutating a registration shared by every plan that names it, so two runs against different snapshots cannot coexist. Sharper: resolveLatest resolves per dispatch, not per run, so a re-registration while a run is in flight can change worker identity between two items of the same run, and nothing downstream reports it (each manifest stays internally consistent and the bundle still verifies).

The audit trail needs nothing extra: DispatchExecutorManifest.capabilities already records the resolved {name, contentHash} set per dispatch, sealed via canonEntry, so a per-item override is captured by existing machinery.

Set one or the other, never both. They are forwarded independently, and the client rejects the combination at fire time with dispatchWork: cannot combine \capabilities` (replace) with `addCapabilities` (append) on the same call`. The item fails on its first dispatch, so a plan that sets both is a plan that cannot run.

A malformed value (anything that is not an array) is ignored, not rejected — matching how inputRefs and pipeline treat malformed carriers, so one bad field cannot fail an item. Note the asymmetry: a malformed value is silently dropped, but a well-formed pair throws.

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.