Plan Specification
Overview
Plans require Signadot CLI v1.6.0 or later and Signadot Operator v1.3.1 or later.
A Signadot Plan is a compiled DAG of action invocations. Plans are
immutable once created and can be executed multiple times with different
parameters. A plan can be produced by compiling a natural-language
prompt, constructed by hand, or authored by a coding agent using the
signadot-plan agent skill.
When a plan is compiled, the prompt references actions by name (e.g.
request-http, check, eval, run-image) and the compiler resolves
each name against the action registry. The
resolved action's body, declared params/outputs, and any
\image{...},
\timeout{...}, or
\extra_inputs_schema{...}
directives are inlined into the step as a snapshot. The plan stores
the inlined snapshot, not a name reference --- so updating an action
later does not affect plans that already reference it.
Here is an example:
id: pl_abc123
spec:
prompt: "Run smoke tests against the checkout sandbox"
params:
- name: cluster
required: true
- name: sandbox
required: true
steps:
- id: run-tests
action:
actionID: act_xyz789
revision: 3
body: |
...
params:
- name: target
required: true
outputs:
- name: result
routingContext:
ref:
sandboxRef: params.sandbox
args:
refs:
target: params.sandbox
output:
result: steps.run-tests.outputs.result
cluster:
fromSandbox: sandbox
status:
createdAt: "2026-01-15T10:00:00Z"
executions: 4
stepDependencies:
run-tests: []
This document details all the fields and constraints associated with a valid plan specification.
id
The id is a server-assigned unique identifier for the plan. It is
immutable and assigned at creation time.
spec
The plan spec contains the compiled DAG and all its components. Plans are immutable once created.
spec.prompt
The prompt is the natural-language prompt body that was compiled to
produce this plan. Empty for hand-created plans.
prompt is optional.
spec.params
The params field is an array of field objects declaring
the external parameters the plan accepts from its caller.
Example:
params:
- name: cluster
required: true
- name: sandbox
required: true
- name: image
default: "latest"
schema: {"type": "string"}
params is optional.
spec.steps
The steps field is an array of step objects that make up
the plan's DAG. Each step is a single action invocation.
steps is required.
spec.output
The output field is a map from output names to
reference expressions, wiring step outputs
to the plan's external interface.
Example:
output:
result: steps.run-tests.outputs.result
summary: steps.summarize.outputs.report
statusCode: steps.send.outputs.capture.response.statusCode
Output references must be rooted at steps. Two forms are supported:
- Whole-output alias (
steps.<id>.outputs.<name>) --- the plan output is the step output verbatim. The runner reuses the step's existing artifact or inline value without any extra upload. - Drill-in (
steps.<id>.outputs.<name>.<field>...) --- the plan output is a sub-field extracted from the step output's JSON. The drill path is recorded at compile time and applied at download time by the API server. The source step output must be valid JSON and must not exceed 1 MB. See drill-in references for full constraints.
output is optional.
spec.cluster
The cluster field declares the plan's
cluster affinity --- a strategy for resolving the
target cluster from the execution's params. If nil, no inference is
performed and the execution caller must supply the cluster directly via
spec.cluster on the
execution. The cluster must always be determinable at execution time;
there is no auto-select fallback.
cluster is optional.
spec.runner
The runner field is the plan author's preferred runner group name.
If set, this runner is used directly at execution time, bypassing
validation filtering. Conflicts with an execution-level runner override.
runner is optional.
status
The plan status is server-managed.
status.compiledFrom
The planID of the source plan, set when this plan was produced by
recompiling an existing plan via POST /plans/{id}/compile. Forms a
lineage chain so re-runs against updated actions can be traced back to
the original plan.
compiledFrom is empty for first-generation plans.
status.createdAt
When the plan was created. Serialized as RFC3339.
status.executions
The number of executions created against this plan.
status.stepDependencies
The step dependency graph, computed at compile time from the
reference expressions in each step's
args.refs. Keys are step IDs; values are the step IDs that step
depends on.
Example:
stepDependencies:
send-request: []
check-status:
- send-request
summarize:
- send-request
- check-status
A step becomes ready to run when all of its dependencies have completed.
Steps
A step is a single action invocation within a plan. Steps form a DAG where dependencies are derived from reference expressions in args.
steps[_].id
The id is a unique identifier for the step within the plan. It is
used in reference expressions (e.g. steps.<id>.outputs.<name>) and
in step dependency tracking.
id is required.
steps[_].action
The action field is the inlined action contract for
this step. It snapshots the action's name, implementation, and parsed
interface so the plan is self-contained.
action is required.
steps[_].routingContext
The routingContext field specifies the sandbox or route group this
step targets. See routing context for details.
routingContext is optional.
steps[_].extraInputs
Steps can extend an action's declared interface in both directions: the plan author may introduce additional named inputs (and outputs) on a per-step basis without modifying the action itself. This keeps actions small and reusable while letting plans wire in whatever additional context a particular invocation needs.
The extraInputs field is an array of field objects
declaring additional named inputs for this step beyond the action's
declared params. These are wired via args.refs like regular params
and become part of the action's evaluation environment.
Some actions declare an
\extra_inputs_schema{...}
policy that constrains how schemaless extra-inputs are handled when a
plan is created --- either filling them in with a fallback schema
(default=...) or rejecting the plan so the plan author has to
declare one explicitly (require). The policy applies to both
compile-from-prompt and hand-created plans.
extraInputs is optional.
steps[_].extraOutputs
Just as plans can instantiate actions with extra inputs, they can do
the same for outputs. The extraOutputs field is an array of
field objects declaring additional named outputs for this
step beyond the action's declared outputs. The action writes these to
./outputs/<name> and the runner reads them back according to their
schema declarations.
Names must not shadow any of the action's declared outputs.
extraOutputs is optional.
steps[_].args
The args field provides the refs and literal values bound to the
action's params and extra inputs. See args.
args is required.
steps[_].condition
The condition field is an expr-lang
boolean expression that controls whether the step executes. If the
condition evaluates to false, the step is skipped.
The condition is evaluated against the step's resolved args --- not
against params or steps directly. All refs in args.refs are
resolved first, and the resulting values are made available to the
condition by their arg name. Literal values from args.values are
also available.
For example, given:
args:
refs:
target: params.sandbox
status: steps.check.outputs.result
values:
runTests: true
the condition environment contains target, status, and runTests.
A valid condition would be:
condition: "runTests == true && status == 'ready'"
JSON values are unmarshaled to native types before evaluation, so numbers, booleans, strings, arrays, and objects can be compared directly in expressions.
Whole-output refs (e.g. steps.X.outputs.Y) are loaded into the
condition environment only if the output file has a .json extension.
Non-JSON outputs (binary, plain text) are available to the action as
file inputs but cannot be referenced in conditions.
condition is optional.
Step Action
The step action is the action contract inlined into a step at compile time. It snapshots the action's ID, revision, implementation body, and parsed params/outputs so the plan is fully self-contained.
Because actions are inlined at compile time, updating an action does
not affect plans that already reference it. To pick up changes to
an action, the plan must be recompiled. The actionID and revision
fields record the exact version of the action that was captured.
steps[_].action.actionID
The actionID is the action's ID at the time the plan was compiled.
actionID is required.
steps[_].action.revision
The revision is the action revision number at the time the plan was
compiled.
revision is required.
steps[_].action.body
The body is the action implementation as markdown with code blocks.
Inputs and outputs are declared inline via \input{...} and
\output{...} syntax.
body is optional.
steps[_].action.params
The params field is an array of field objects parsed
from \input{...} directives in the action's body. These define
the inputs the action accepts.
params is optional.
steps[_].action.outputs
The outputs field is an array of field objects parsed
from \output{...} directives in the action's body. These define
the outputs the action produces.
outputs is optional.
steps[_].action.extraInputsSchemaPolicy
The extraInputsSchemaPolicy field is the action author's declared
policy for how plan creation should resolve missing schemas on this
step's extraInputs. Snapshotted from the
action's
\extra_inputs_schema{...}
directive when the plan is created.
| Field | Description |
|---|---|
default | A JSON Schema applied to extra-inputs the plan author left schemaless |
require | When true, plan creation fails if any extra-input on a step of this action is schemaless |
At most one of default or require is set.
extraInputsSchemaPolicy is optional.
steps[_].action.image
The image field declares that the action's shell fenced block runs
inside an OCI image via actionbox run-image (and/or carries env
vars for the script). Snapshotted from the action's
\image{...} directive at compile
time.
| Field | Description |
|---|---|
literal | The image reference itself, e.g. "alpine:3.19"; empty means run in the runner's base image |
inputName | The name of a declared input whose value at run time is the image reference |
env | Map of KEY to literal env value, merged into the script's env in either dispatch path |
envFromInput | Map of KEY to input name; the input's value at run time is the env value |
At most one of literal or inputName is set; both may be empty
when only env/envFromInput are declared, in which case the action
runs in the runner's base image with the directive's env applied.
Reserved runner-set keys (HOME, TMPDIR, PATH, SSL_CERT_FILE,
SIGNADOT_*) are silently filtered from env and envFromInput so
they cannot override the runner's contract values.
image is optional --- when nil, the action runs directly in the
runner with no extra env.
steps[_].action.timeout
A literal wall-clock duration string parsed by Go's
time.ParseDuration
(e.g. "60s", "5m"). Snapshotted from the action's
\timeout{"..."} directive at
compile time. Empty means the runner's system default cap applies.
Mutually exclusive with timeoutInputName.
timeout is optional.
steps[_].action.timeoutInputName
The name of a declared input whose value at run time is the duration
string. Snapshotted from
\timeout{{"input":"NAME"}}.
Mutually exclusive with timeout. An input that resolves to the
empty string falls through to the runner's system default cap.
timeoutInputName is optional.
Reference Expressions
Reference expressions are used in args.refs, spec.output, and
routing context ref fields to wire data between plan parameters, step
outputs, and the plan's external interface.
References are path expressions, not general expressions. They are
parsed using the expr-lang AST parser but only identifier access and
member access (.field or [index]) are permitted. Binary operators,
function calls, conditionals, and other expression forms are rejected.
The reference environment has two roots:
| Root | Description |
|---|---|
params | Plan-level input parameters provided at execution time |
steps | Outputs of completed upstream steps |
Param references
Param references have the form params.<name> and resolve to the
raw JSON value of the named plan parameter. Drill-in is supported
for structured parameter values:
params.cluster → whole param value
params.config.region → drill into a JSON object field
params.items[0].name → drill into array element, then field
Step output references
Step output references have the form
steps.<stepID>.outputs.<outputName> and resolve to the named
output of a completed upstream step. Any step referenced in a ref
becomes a dependency --- the referencing step will not execute until
the referenced step completes.
Drill-in is supported for step outputs that contain valid JSON (see drill-in references):
steps.send.outputs.capture → whole output
steps.send.outputs.capture.response.statusCode → drill into field
steps.send.outputs.data[2].fields[0].value → array + field drill
Drill-in references
Drill-in references extract a sub-field from a structured JSON value.
They extend a base reference (params.<name> or
steps.<id>.outputs.<name>) with additional path segments.
Drill-in has the following constraints:
- The source field must declare a schema. A stringly-typed source has no internal structure to drill into, so drilling into a source with no declared schema is rejected at plan creation. Always declare schemas on plan params and step outputs that downstream steps will drill into.
- The source value must be valid JSON at run time. Non-JSON outputs (binary, plain text) can only be referenced as whole values.
- The source value must not exceed 1 MB in size.
- Each path segment navigates into either a JSON object (by field
name) or a JSON array (by integer index). Drilling into
null, strings, numbers, or booleans produces an error. - Drill segments themselves are not validated at plan creation beyond checking that the base output name exists on the source step. Path validity is checked at runtime when the value is actually walked.
A related rule applies to whole-value refs in args.refs
even without drill-in: if the destination slot (action param or
extraInput) declares a schema but the
referenced source declares none, the plan is rejected at creation
time. Type information can be discarded when a structured source
flows into a stringly-typed destination, but it cannot be invented out
of nothing. The validator does not walk into nested types --- the
drilled value's effective schema is treated as JSON Schema "any".
Args
Args provide the actual values for an action invocation's parameters. Map keys correspond to the action's declared param names (or extra input names).
args.refs
The refs field is a map from param names to
reference expressions. Reference expressions
wire plan-level parameters and outputs of upstream steps into
downstream step inputs.
Example:
args:
refs:
target: params.sandbox
previousResult: steps.step1.outputs.result
statusCode: steps.send.outputs.capture.response.statusCode
refs is optional.
args.values
The values field is a map from param names to literal JSON values.
Used to pass constant values directly to action parameters.
Example:
args:
values:
timeout: 30
verbose: true
config: {"retries": 3}
values is optional.
Fields
A field defines a named parameter or output declaration. Fields are
used in spec.params, steps[_].action.params,
steps[_].action.outputs, and steps[_].extraInputs.
field.name
The field identifier, unique within the containing params or outputs array.
name is required.
field.required
Indicates whether the field must be provided. Defaults to false.
required is optional.
field.default
The default value used when the field is not provided. Must conform
to schema if set.
default is optional.
field.schema
An optional JSON Schema object describing the field's type and constraints.
Example:
schema: {"type": "integer", "minimum": 1}
schema is optional.
field.schemaRef
An optional reference to an external schema (name, file, or URL).
schemaRef is optional.
field.metadata
A map of presentation hints and additional key-value pairs.
Well-known keys:
| Key | Description |
|---|---|
contentType | MIME type of the field value |
displayName | Human-readable label for UI display |
encoding | Encoding format (e.g. base64 for inlined binary values) |
metadata is optional.
Routing Context
A routing context specifies the routing target for a step. It can be specified as either a literal target or a reference expression, but not both.
routingContext.literal
An inline routing target specifying the sandbox, route group, or routing key directly.
routingContext.ref
A routing target ref containing reference expressions that resolve to a routing target at execution time.
Routing Target
A routing target identifies where to direct traffic. Exactly one field must be set.
routingTarget.sandbox
The name of the target sandbox.
routingTarget.routeGroup
The name of the target route group.
routingTarget.routingKey
The routing key used to direct traffic.
Example:
routingContext:
literal:
sandbox: my-sandbox
Routing Target Ref
A routing target ref contains reference expressions that resolve to routing target values at execution time. Exactly one field must be set.
routingTargetRef.sandboxRef
A reference expression that resolves to a sandbox name.
routingTargetRef.routeGroupRef
A reference expression that resolves to a route group name.
routingTargetRef.routingKeyRef
A reference expression that resolves to a routing key.
routingTargetRef.anyRef
A reference to a plan parameter whose value is a routing target. This is polymorphic --- the caller decides whether the target is a sandbox, route group, or routing key at execution time.
Example:
routingContext:
ref:
sandboxRef: params.sandbox
Cluster Affinity
Cluster affinity declares how a plan's target cluster is resolved at
execution time, given the execution's params and an optional explicit
cluster on the execution. At most one field must be set. The cluster
must always be determinable --- there is no auto-select fallback. If
the affinity is nil, or if the affinity-resolved value is missing
(e.g. the named param is not provided), the execution caller must
supply the cluster directly via
spec.cluster on the
execution; otherwise the execution is rejected. If both a resolved
value and an explicit cluster are present, they must agree.
cluster.fromCluster
Names a plan parameter whose value is a cluster name directly.
cluster.fromSandbox
Names a plan parameter whose value is a sandbox name. The cluster is inferred from the sandbox's cluster.
cluster.fromRouteGroup
Names a plan parameter whose value is a route group name. If the route group is bound to a single cluster, it resolves automatically. If the route group spans multiple clusters, the execution must specify a cluster (validated against the route group's cluster set).
cluster.fromAnyTarget
Names a plan parameter whose value is a routing target. The cluster is inferred from the variant provided at execution time (sandbox resolves to the sandbox's cluster, route group resolves to the route group's cluster).
cluster.pattern
A glob pattern matched against cluster names (e.g. prod-*, *).
If the pattern matches exactly one connected cluster with an
available runner, it is used automatically. Otherwise, the execution
must specify a cluster.
Example:
cluster:
fromSandbox: sandbox
JSON Value Handling
Plan parameters, step outputs, and arg values are all represented as JSON. The runtime applies type coercion in several contexts:
In conditions
When a condition is evaluated, each arg value is JSON-unmarshaled to its native type before being passed to the expr-lang evaluator:
| JSON type | Go / expr-lang type |
|---|---|
| string | string |
| number | float64 |
| boolean | bool |
| object | map[string]any |
| array | []any |
| null | nil |
If a value cannot be parsed as JSON, it is passed as a raw string.
This allows conditions to use typed comparisons directly
(e.g. count > 5, enabled == true).
In routing context refs
When a reference expression is used to resolve a routing target (sandbox name, route group name, or routing key), the resolved value is coerced to a string:
- JSON strings are unquoted (e.g.
"my-sandbox"becomesmy-sandbox). - Other JSON scalars (numbers, booleans) are converted to their string representation.
- Non-JSON values are trimmed of whitespace and used as-is.
In action parameters
How a resolved value is written to an action's execution context
depends on whether the parameter declares a schema:
- With schema: the value is written as a
.jsonfile with the raw JSON bytes preserved verbatim. - Without schema: JSON string values are unquoted (outer quotes stripped) so the action receives plain text. Non-string JSON values are written as-is.