Skip to main content

Plan Specification

Overview

Requirements

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.

note

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.

note

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.

FieldDescription
defaultA JSON Schema applied to extra-inputs the plan author left schemaless
requireWhen 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.

FieldDescription
literalThe image reference itself, e.g. "alpine:3.19"; empty means run in the runner's base image
inputNameThe name of a declared input whose value at run time is the image reference
envMap of KEY to literal env value, merged into the script's env in either dispatch path
envFromInputMap 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:

RootDescription
paramsPlan-level input parameters provided at execution time
stepsOutputs 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:

KeyDescription
contentTypeMIME type of the field value
displayNameHuman-readable label for UI display
encodingEncoding 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 typeGo / expr-lang type
stringstring
numberfloat64
booleanbool
objectmap[string]any
array[]any
nullnil

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" becomes my-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 .json file 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.