Skip to main content

script

The script step type runs inline code with an explicit interpreter. Use it for a small, portable script body instead of encoding a heredoc in a shell command.

steps:
- name: check generated files
type: script
interpreter: python3
working_directory: .
script: |
from pathlib import Path
if not Path("generated/config.yaml").exists():
raise SystemExit("missing generated config")

Fields​

FieldDescription
interpreterRequired executable, or starlark for the embedded runtime. Supports templates.
scriptRequired inline program text. Supports templates.
working_directoryOptional process directory. Supports templates.
envOptional environment map. Only these declared values are rendered as templates.
outputOptional output mode: raw, log, viewport, or none. Any other value is rejected before the step runs. Script steps default to raw.
showOptional display settings such as show.labels. See Step labels.
retryOptional retry policy that reruns the whole step on failure.
timeoutOptional duration such as 30s or 5m. Atmos stops the interpreter and its subprocesses when it elapses and fails the step with step timed out. The timeout bounds the whole step, including every retry attempt and the wait between attempts, so retries never extend it.
whenOptional condition that decides whether the step runs.
outputsOptional named values derived from the step result, available as {{ .steps.<name>.outputs.<key> }}.
containerRejected for interpreter: starlark, which runs inside Atmos. See Containers and dry runs.

Do not set command on a script step. Use type: shell for a real shell command and type: script when the interpreter and body are the intentional interface.

Environment and templates​

Atmos renders the values you declare under the step's env as templates. The ambient process environment, which includes everything inherited from your shell, is never rendered. It reaches the interpreter and its child processes exactly as it is, so a variable such as FOO='{{ bad' neither fails the step nor changes value. Use !literal on a declared env value to keep that value unrendered too.

Keep braces out of template rendering​

Atmos renders the script body as a Go template before the interpreter runs. Expressions such as {{ .vars.stage }} can be replaced with their values, while malformed or unresolved expressions can cause template errors. A lone }} is ordinary text. Tag the body with !literal to prevent unintended substitution and template errors and run it exactly as written:

steps:
- name: report
type: script
interpreter: starlark
script: !literal |
row = {"name": "vpc", "status": "ok"}
print("{} is {}".format(row["name"], row["status"]))
print("Atmos templates look like {{ .vars.stage }}")

!literal works the same in custom commands, workflows, hooks, and in parallel and matrix children. It applies to the field it tags: a literal script next to a templated interpreter or env value still renders the other fields. It also works on interpreter, working_directory, and individual env values. A script loaded with !include is still rendered as a template.

Atmos Automation Language​

The Atmos Automation Language is a Python-like language for automating commands, workflows, and lifecycle hooks. Built on Starlark and embedded in Atmos, it provides component context, parallel execution, retries, and timeouts.

Set interpreter: starlark to run a script inside Atmos without installing another interpreter. Use functions for reusable logic and steps.parallel for concurrent calls:

steps:
- name: calculate
type: script
interpreter: starlark
env:
REGION: us-east-1
script: |
def api():
return {"service": "api", "region": env["REGION"]}

def worker():
return {"service": "worker", "region": env["REGION"]}

output = steps.parallel(
functions = [api, worker],
max_concurrency = 2,
)

Pass function references (api), not calls (api()). The returned list follows input order, even when branches finish in another order. Atmos waits for every branch before returning.

Step output​

Assign the top-level output global to set the step's value. A string is used as-is, and any other value is JSON-encoded:

output = "abc" # {{ .steps.<name>.value }} is: abc

Or, for a structured value:

output = {"service": "api"} # {{ .steps.<name>.value }} is: {"service":"api"}

A value that cannot be encoded as JSON, such as a function, fails the step with the error output` must be a string or JSON-encodable value: <reason>, followed by a hint to assign a string, number, bool, list, or dict. Without output, the captured stdout (everything passed to print()) is the step's value.

A later step reads a dict or list output as its JSON text, never as Go map syntax, so {{ .steps.<name>.value }} renders {"service":"api"} for the dict above. JSON text contains double quotes. When you place it inside a double-quoted shell string or a Starlark string literal, the quotes end that string early and the shell strips them. Wrap the template in single quotes in a shell command, or pass the value through env and read it there:

steps:
- name: first
type: script
interpreter: starlark
script: |
output = {"n": 3}
- name: show
type: shell
command: echo '{{ .steps.first.value }}' # {"n":3}
- name: read
type: script
interpreter: starlark
env:
FIRST: "{{ .steps.first.value }}"
script: |
n = json.decode(env["FIRST"])["n"]

Atmos treats stdout as data and stderr as messages for people, so use print() for values the step produces and ui.info(), ui.success(), or ui.warning() for status and progress messages. ui.* output goes to stderr and never becomes the step's value.

Logging​

Use log.trace(), log.debug(), log.info(), log.warn(), and log.error() for diagnostics that matter when you troubleshoot a run but not when it succeeds. Each call takes a message and optional keyword arguments, which are written as structured key/value pairs through the Atmos logger. The logger honors --logs-level (or ATMOS_LOGS_LEVEL) and logs.file, so a call below the configured level prints nothing. Output never goes to stdout and never becomes the step's value, and secrets known to Atmos are masked.

FunctionUse it forWhere it goes
print()Data the step produces.stdout. Becomes the step's value when output is unset.
ui.info(), ui.success(), ui.warning()Status and progress for people.stderr, themed.
log.*()Diagnostics for troubleshooting.The Atmos log, only at the configured log level.
log.debug("resolved component", component="api", attempts=2)
log.warn("falling back to the default region", region="us-east-2")
$ atmos workflow deploy --logs-level=Debug
DEBU resolved component step=deploy component=api attempts=2

Every message includes a step field with the step name. Inside a steps.parallel group with more than one task, a task field names the running task, for example task=grp/inner for a nested group. A group with a single task adds no task field, just as it adds no [task] output prefix. Field values can be strings, numbers, booleans, or None; any other value is logged using its str() form. Field names must be identifiers, and a field you pass named step or task replaces the automatic one.

Language features​

Top-level if, for, and while statements, set(), and recursion are available, in the entry script and in loaded modules:

def fact(n):
return 1 if n <= 1 else n * fact(n - 1)

squares = []
for i in range(4):
squares.append(fact(i))

output = squares

Module-level names are assigned once. Rebinding a global, such as x = 1 followed by x = 2, n += 1 at the top level, or assigning output in both branches of an if/else, fails with cannot reassign global. Choose the value with a conditional expression, or compute it inside a function:

# A conditional expression.
output = "blue" if env["REGION"] == "us-east-1" else "green"
# A function that returns the value.
def main():
if env["REGION"] == "us-east-1":
return "blue"
return "green"

output = main()

To carry changing state, mutate a dictionary or list instead of rebinding a name. An indexed update such as state["n"] += 1 mutates the dictionary and keeps state bound to the same object. Mutate it before handing it to steps.parallel, because everything reachable from a parallel task becomes immutable at dispatch:

state = {"n": 0}
for name in ["api", "worker"]:
state["n"] += 1

def count():
return state["n"]

output = steps.parallel(functions = [count])

Parameterized tasks, retries, and timeouts​

Use steps.task to describe work without executing it:

def deploy(name):
result = exec.run(["deployment-tool", "deploy", name])
return result.stdout

output = steps.parallel(
tasks = [
steps.task(
name = name,
function = deploy,
args = [name],
retry = {
"max_attempts": 3,
"initial_delay": "2s",
},
timeout = "5m",
)
for name in ["api", "worker", "scheduler"]
],
max_concurrency = 2,
fail_fast = True,
)
APIBehavior
steps.parallel(functions=..., tasks=..., max_concurrency=4, fail_fast=False)Supply exactly one collection; empty collections return []. Nested groups each have their own limit.
steps.task(name, function, args=[], kwargs={}, retry=None, timeout="")Named deferred call. Names must be unique within a parallel group.
exec.run(argv, working_directory=..., env={...}, output="stream", check=True)Runs an argument list without a shell. Returns .stdout, .stderr, and .exit_code. By default, output streams live and failure raises an error.
ui.info(message), ui.success(message), ui.warning(message)Write through Atmos's UI channel.
log.trace(message, **fields), log.debug(...), log.info(...), log.warn(...), log.error(...)Write diagnostics to the Atmos logger, filtered by the configured log level. See Logging.
json.encode(value), json.decode(text)Convert structured values and JSON.
fs.read_file(path)Read local file contents as a string. Relative paths use the step working directory, including calls from loaded modules. Absolute paths are accepted.
regex.search(pattern, text)Return whether the pattern matches anywhere in the text.
regex.findall(pattern, text)Return complete matches in order, regardless of capture groups.
regex.replace(pattern, replacement, text)Replace every match with literal replacement text.

Regular expressions use Go/RE2 syntax and support inline flags such as (?m). Lookaround and pattern backreferences are unsupported. Replacement text is literal: $1 is written as $1, without capture expansion. File reads use the same injectable reader as module loading, allowing tests to supply file contents without disk access. File writes and directory discovery are not available yet.

Both exec.run and component.exec take these options:

output

The default, "stream", shows the subprocess output live in the step output and also captures it. Use "capture" to capture stdout and stderr without showing them, which is useful when inspecting secret-bearing responses.

check

The default, True, raises when the process exits nonzero; the error names the command and exit code and lists the last lines of stderr. Pass False to get the result back and assert on .exit_code, .stdout, and .stderr yourself.

working_directory, env

The process directory and extra environment variables for this call.

A process that cannot start, such as a missing command or a nonexistent directory, always raises, regardless of check. Cancellation, signals, and transport errors also raise.

To test an expected command failure, pass check=False, then assert both its exit code and its diagnostic:

def expect_timeout(argv):
result = exec.run(argv, check=False, output="capture")
if result.exit_code == 0:
fail("expected the command to fail")
if "deadline exceeded" not in result.stdout + result.stderr:
fail("command failed for an unexpected reason")

A handled nonzero exit does not itself trigger task retries. Call fail() when the result should fail the current task and enter its retry policy.

Terminal output uses the shared Atmos I/O layer: print() writes to the step's stdout channel, ui.* formats messages on its stderr channel, and subprocesses stream through those same writers. Step output settings, masking, test capture, and cast recording therefore apply. Captured values remain available to scripts; masking applies when displaying them. File reads use the injectable filesystem reader, independently of terminal output. There is no Starlark stdin/prompt API yet.

For example, a Starlark validator can read a recorded cast, decode its output events, and check the text without a Python interpreter:

def validate_cast(path):
lines = fs.read_file(path).splitlines()
events = [json.decode(line) for line in lines[1:] if line.strip()]
text = "".join([event[2] for event in events if event[1] in ["o", "e"]])
plain = regex.replace(r"\x1b\[[0-9;]*m", "", text)
if "All 3 component plans ready." not in plain:
fail("missing successful component plans")

validate_cast("release-plan.cast")

Retries repeat the entire failed function, including its earlier successful operations. Successful sibling branches are not repeated. A task's timeout bounds the whole task, including retries and delays. Retry fields use the Atmos retry configuration; the conditions field for matching subprocess output does not apply to function tasks. Set max_attempts or a timeout on every task that has a retry policy: a policy without max_attempts keeps retrying until the task succeeds, its timeout expires, or the workflow is canceled. Without a retry policy, a task runs once. A task that exceeds its timeout fails with task "<name>" timed out after <duration>.

By default, all independent branches finish and failures are aggregated. With fail_fast=True, a branch that exhausts its retries cancels running branches and skips queued branches. The group still waits for cancellation to finish.

Before dispatch, Atmos freezes everything the tasks can reach: the task functions, their closures, default values, and arguments, and every global of each task function's module, including globals of loaded modules. A task's return value is frozen as well, once the task finishes. Create mutable lists and dictionaries inside each function and return results. Process directories and environment overrides belong to each invocation; they do not change Atmos's own working directory or environment.

Live output​

Script output appears line by line as the script produces it, so a long-running script shows progress while it runs. Inside a steps.parallel group with more than one task, Atmos prefixes each line with the task name and keeps every line intact, so output from concurrent tasks never interleaves mid-line:

[api] deploying api
[worker] deploying worker
[api] api ready
[worker] worker ready

Step labels​

Like shell steps, a script step streams its output as-is. It does not print a [name] label before the output or a ✓ name completed line after it. Set output to choose a different output mode, or set show.labels: true to print the labels:

steps:
- name: summarize
type: script
interpreter: starlark
show:
labels: true
script: |
print("done")

Loading files​

Use the existing YAML include function for a script body:

steps:
- name: deploy
type: script
interpreter: starlark
working_directory: .
script: !include scripts/deploy.star

A script read from a local file knows where that file lives. Its load() calls resolve relative to that file, whether the path is bare (lib/helpers.star) or starts with ./ or ../, and so do the load() calls of the modules it loads. Each kind of path resolves like this:

Path used inResolves against
!include in a workflow manifest or a custom command./x and ../x: the directory of the file that contains the tag. x/y: the Atmos project base_path, including a --base-path flag or ATMOS_BASE_PATH override. Absolute paths are used as written. The result is the same wherever you run Atmos from.
!include in a stack manifest hook (kind: step or kind: steps)The same lookup as any stack manifest !include: relative to the manifest file, then to the project base_path.
load() in a script read from a file (!include, !include.raw, or a standalone .star file)The directory of that file.
load() in an inline script: | bodyThe step's working_directory.
fs.read_file and exec.runThe step's working_directory.

With this project layout:

atmos.yaml # base_path: "./"
scripts/
deploy.star
lib/
helpers.star
stacks/workflows/deploy.yaml

the workflow includes the script by its path from the project base path, and the script loads its helper relative to its own file:

stacks/workflows/deploy.yaml
workflows:
deploy:
steps:
- name: deploy
type: script
interpreter: starlark
script: !include scripts/deploy.star
scripts/deploy.star
load("lib/helpers.star", "deploy")

output = deploy("api")
scripts/lib/helpers.star
def deploy(name):
return {"service": name, "status": "ready"}

Tracebacks for a script read from a file name that file and the line within it, relative to the project base path when available, for example scripts/deploy.star:3:5. Files outside the project retain their absolute paths. If a Go template in the script emits extra lines, the reported lines refer to the rendered script.

A hook script included from a stack manifest knows its file too. When a script that has an interpreter sibling comes from a local !include or !include.raw file (without a YQ expression, and never from a remote source), Atmos records the file in an internal script_source key next to it, plus a script_source_sha256 fingerprint of the included content:

stacks/deploy/dev.yaml
components:
terraform:
mock:
hooks:
check:
kind: step
type: script
events: [before.terraform.plan]
with:
interpreter: starlark
script: !include scripts/hooks/check.star

atmos describe component and atmos describe stacks show the recorded provenance under the hook, as the path relative to the project base path when the file is inside the project (an absolute path otherwise):

hooks:
check:
with:
interpreter: starlark
script: "..."
script_source: scripts/hooks/check.star
script_source_sha256: 3f1c...e9a2

Atmos owns both keys: do not write them by hand. An include in the same mapping replaces any value you set. The hook runner honors script_source only while the step's script still hashes to script_source_sha256, so provenance cannot go stale. Stack inheritance deep-merges maps: when a child stack replaces an included script with an inline one, the merged hook still shows the base's script_source and fingerprint in describe output, but the fingerprint no longer matches and Atmos ignores the inherited source. The inline script resolves load() against the step's working_directory, with no need to remove the inherited keys. A script that contains Go templates still matches, because the fingerprint is checked against the script before templates are rendered.

Modules are cached per invocation; cyclic loads fail with an error. Loaded functions can be used in steps.parallel and steps.task.

Working directory and environment​

The working_directory must exist; Atmos checks it before the script runs and fails the step with a hint when it is missing. It applies to fs.read_file, exec.run, and load() in inline scripts. A component.exec call defaults to the component directory instead.

The env dictionary contains only explicitly declared step inputs: the step's own env: entries. In a custom command, command-level env: entries are not in env; they reach child processes through the process environment instead. Child processes inherit the effective execution environment, including authentication and command environment settings. See Environment and templates for what Atmos renders.

Containers and dry runs​

Embedded Starlark runs inside Atmos, so it cannot run in a container. A script step under an enabled workflow or step container fails validation before the workflow starts. Set container: false on the step:

workflows:
build:
container:
image: alpine:3.20
steps:
- name: plan
type: script
interpreter: starlark
container: false
script: !include scripts/plan.star

With atmos workflow --dry-run, Atmos parses each Starlark script step, so syntax errors are reported, and runs nothing: no code, no processes. Script steps inside parallel and matrix groups run nothing either.

Errors​

A script failure is reported as a single-line message, with the Starlark traceback shown below it. For example, fail("missing generated config") produces:

Error: workflow step execution failed: max attempts (1) exceeded, last error: starlark execution failed: fail:
missing generated config

Traceback (most recent call last):
s:1:5: in <toplevel>
Error in fail: fail: missing generated config

A failed exec.run adds the last lines of the command's stderr, and a task that exceeds its timeout reads task "<name>" timed out after <duration>.

Custom components​

This recording shows a Starlark custom command planning three custom application components using a loaded function, deferred tasks, and a concurrency limit of two. The command reads resolved stack configuration without changing infrastructure.

Starlark custom commands and parallel functions
 
00:00.0 / 00:00.0

Resolve a component with components.get(name, stack, type). In workflows and hooks, pass the stack explicitly. A custom command that declares a component: already has its component and stack in scope as ctx.component:

commands:
- name: deploy-peers
arguments:
- name: component
provides: component
required: true
flags:
- name: stack
shorthand: s
provides: stack
required: true
component:
type: application
steps:
- name: deploy
type: script
interpreter: starlark
script: |
stack = ctx.component.stack

def deploy(name, stack):
peer = components.get(
name = name,
stack = stack,
type = "application",
)
return peer.exec(["./deploy"]).stdout

output = steps.parallel(
tasks = [
steps.task(name=n, function=deploy, args=[n, stack], timeout="5m")
for n in ["api", "worker"]
],
max_concurrency = 2,
)

Where ctx.component is set​

The execution context decides what ctx.component holds:

Contextctx.component
Custom command with component:The command's component. Atmos resolves it on first access, so scripts that never use it never pay for the lookup.
HookThe hook's component, plus ctx.hook and ctx.operation.
WorkflowNone, because no component is in scope.

parallel and matrix children inherit ctx.component and components.get from the step that contains them. Atmos resolves each components.get result once per run and reuses it, including across parallel tasks.

Lazy ctx.component attribute access uses the invocation deadline, because the Starlark attribute interface does not receive the calling task. To bound component resolution by a task timeout, call components.get inside that task with explicit name, stack, and type values. A caller waiting for another task to resolve the same component can cancel independently. Read any ctx.component reference fields before starting parallel tasks; accessing them inside the task also triggers lazy resolution.

In a workflow, ctx.component is None, so a script that reads ctx.component.stack fails with NoneType has no .stack field or method. Name the stack explicitly instead:

workflows:
deploy-peers:
steps:
- name: deploy
type: script
interpreter: starlark
script: |
def deploy(name):
peer = components.get(name = name, stack = "dev", type = "application")
return peer.exec(["./deploy"]).stdout

output = steps.parallel(
tasks = [
steps.task(name=n, function=deploy, args=[n], timeout="5m")
for n in ["api", "worker"]
],
max_concurrency = 2,
)

Handles expose name, type, stack, implementation, path, vars, settings, metadata, env, and config. Their configuration is resolved through Atmos's inheritance and YAML-function pipeline and is read-only. The logical name can differ from the implementation directory when components inherit shared code.

The component.exec function accepts the same arguments as exec.run and defaults to the component directory. It inherits the invocation environment, overlays component env, preserves custom-command and explicit step env overrides, and finally applies per-call env overrides. Component lookups are serialized; the parallel runner executes independent processes concurrently.

Arbitrary typed-step dispatch and Starlark test-file discovery are not available yet.

Standalone executable scripts​

Use an Atmos shebang to run an automation program directly:

#!/usr/bin/env atmos
dependencies.tools("jqlang/jq", "1.7.1")

ui.info("Script arguments: %s" % ctx.args)
exec.run(["jq", "--version"])

After chmod +x deploy.star, run ./deploy.star dev, or use atmos ./deploy.star dev. A leading .star file or an explicit path with an Atmos shebang selects script execution. No arguments preserve the default CLI screen; bare extensionless names remain CLI commands. Script arguments such as --help belong to the script and are exposed through immutable ctx.args.

ctx.script.path and ctx.script.directory identify the script file; steps whose script comes from a local !include set them too. Local load() paths resolve against the script file; commands retain the caller's working directory. Each dependencies.tools(name, version) call installs a missing tool through the existing dependency manager and updates PATH for subsequent process calls in this invocation. Declare tools before parallel tasks; declarations inside branches are rejected. Repeated identical pins are cached; conflicting versions fail. Single-file scripts can themselves be packaged as type: http, format: raw toolchain entries and installed with atmos toolchain install owner/tool@version. The Atmos interpreter must be on PATH.

Pass the script filename directly to atmos, before any script arguments. Global Atmos options before the script filename are not supported.

Use atmos.toolchain("install", "jq@1.7.1") for an explicit toolchain CLI operation. It returns stdout, stderr, and exit_code, with the same output and error policies as atmos.run. Optional flags={...} and args=[...] pass CLI options. This operation does not change the calling script's PATH; use dependencies.tools when subsequent script calls need the installed tool.

Declaring a standalone command​

Standalone scripts can declare their interface using Atmos's native flag handling:

#!/usr/bin/env atmos
def validate(args, flags):
if flags["replicas"] < 1:
fail("replicas must be positive")

def main(args, flags):
print("{}: {} replicas".format(args["service"], flags["replicas"]))

cli.command(
description = "Report a service's replica count",
args = [cli.arg("service", description="Service name")],
flags = [cli.flag("replicas", type="int", shorthand="r", default=2)],
validate = validate,
run = main,
)

After saving this as replicas.star and making it executable, run ./replicas.star api -r 3 or ./replicas.star --help. Help skips both callbacks; parse failures and failed validation prevent main from running. Keep work with side effects inside main, since ordinary top-level statements still execute when the script loads.

DeclarationOptions
cli.arg(name, ...)description, required (default True). Required arguments precede optional ones. Omitted optional arguments are None.
cli.flag(name, ...)type ("string", "int", "bool", or "string_list"), default, shorthand, description, required, choices, and an optional env binding.
cli.command(run=main, ...)name (defaults to the script filename), description, args, flags, and an optional validate callback. Call once per standalone invocation, outside parallel tasks.

main(args, flags) and validate(args, flags) receive immutable dictionaries. Names are dictionary keys, including hyphenated flags. Integers and booleans arrive as typed values; string_list flags accept comma-separated or repeated values. choices applies to string and string-list flags. A required flag must be supplied on the command line or through its declared environment binding; it cannot also declare a default. Boolean flags use defaults and cannot be required.

Command-line values override environment bindings, which override defaults. The environment binding is optional; scripts always read the parsed flags dictionary directly. validate may call fail() with an explanation, return False to reject the input, or return None or True to accept it. The value returned by main is the return value of cli.command; assign it to output to emit structured data.

Unknown flags and invalid values fail through the native parser. -- ends flag parsing so later values are positional arguments. All arguments after the script filename belong to the script, including flags such as --config and --help. Scripts that do not use cli.command continue to receive the original ctx.args.

Reading command inputs in script steps​

Custom-command script steps read parsed inputs directly through ctx.flags and ctx.arguments, without templates or environment mappings:

replicas = int(ctx.flags["replicas"])
service = ctx.arguments["service"]
print("{}: {} replicas".format(service, replicas))

These dictionaries preserve the host command's value types. A custom command flag declared as type: string still needs int(...) when used in arithmetic, while a type: int flag arrives as an integer and a type: bool flag as a boolean. Positional arguments are always strings. An optional argument that has no default and was not supplied is an empty string, and an omitted argument with a default holds its default. Workflow flags are strings, and ctx.flags in a workflow always includes stack (an empty string when no stack applies). Inputs are immutable and are inherited by control and test children. Contexts without declared inputs expose empty dictionaries. Standalone command callbacks receive their parsed values as the args and flags callback parameters.

Calling Atmos commands​

Built-in commands are callable directly by name. Pass CLI positional arguments as strings, options through flags, and additional literal arguments through args:

atmos.vendor("pull", flags={"component": "vpc", "stack": "dev"})
atmos.scaffold("generate", "service-template", "services/api", flags={"dry-run": True})
components_json = atmos.list("components", flags={"stack": "dev", "format": "json"}, output="capture")
component = atmos.describe("component", "api", flags={"stack": "dev"}, output="capture")
atmos.config("get", args=["base_path"])
atmos.version()

Generic wrappers are available for about, ai, ansible, atlantis, auth, aws, azure, cast, ci, completion, composition, config, container, describe, devcontainer, docs, emulator, env, gcp, git, helmfile, help, init, kubernetes, list, lsp, mcp, packer, pro, profile, sbom, scaffold, secret, stack, store, support, theme, validate, vendor, version, and workflow. These wrappers retain each command's native behavior, including authentication and experimental notices. They return process results; decode JSON stdout with json.decode when structured values are needed.

Use atmos.toolchain(command, tool="", flags={}, args=[]) for tool operations. Use atmos.run(argv) for any Atmos command, including custom commands. Use atmos.terraform(command, component=..., stack=...) and atmos.helm(command, component=..., stack=...) for component operations:

plan = atmos.terraform(
"plan",
component = "vpc",
stack = "dev",
flags = {"detailed-exitcode": True},
)

validation = atmos.run([
"casts", "validate", "demo", "fixtures", "starlark", "release-plan",
])

These calls invoke the current Atmos executable through the same process runner as exec.run. Arguments are passed directly without shell parsing. These functions accept working_directory, env, output="stream", and check=True, and return .stdout, .stderr, and .exit_code. Failures raise unless check=False; launch, cancellation, signal, and transport errors always raise. A Terraform plan with flags={"detailed-exitcode": True} accepts exit code 2 as changes detected. The generic atmos.run uses ordinary exit-code handling.

Command wrappers and component helpers accept a flags dictionary and an args list. Bare flag names become --name; use an explicit prefix for native tool flags, such as "-var". The detailed-exitcode flag automatically uses Terraform's single-dash spelling. Values may be strings, integers, booleans, or lists to repeat a flag. A value of True emits the flag, and False emits =false. Spaces and shell metacharacters remain literal argument text.

Atmos calls default to the invocation directory, preserving configuration discovery even when a script runs in a component directory. An explicit relative working_directory is relative to that invocation directory. The child inherits the effective process environment; parent CLI-only flags are not automatically replayed. Pass extra CLI options through args or atmos.run when needed.

Atmos calls work inside ordinary functions passed to steps.parallel. Apply retries and timeouts using steps.task; retries repeat the function containing the call. Calls trigger the normal command lifecycle, including hooks. When invoking a command from a hook, use event scoping or the command's supported hook-skipping options to avoid recursively invoking the same hook.

Lifecycle hook context​

Starlark scripts work in kind: step, kind: steps, and type: test hooks. They receive read-only lifecycle context, including in parallel functions:

ObjectFields
ctx.componentname, type, stack, implementation, path, vars, settings, metadata, env, config, and exec
ctx.hookname and canonical event, such as after.terraform.apply
ctx.operationcommand, status, exit_code, error, stdout, stderr

The component contains the hook runner's execution snapshot, including inherited settings. It does not re-resolve the stack or fetch Terraform outputs. Its path matches the hook runner's component working directory, including provisioned workdirs. Use dictionary access for settings and vars. To look up a different component, call components.get(name, stack, type) with an explicit stack, just as in a workflow.

For before hooks, operation status, exit code, and error are None. After hooks expose the supplied outcome; an absent error is None. The command is the canonical operation, such as terraform apply. These fields describe the parent operation, not processes launched by the hook. Ordinary lifecycle hooks do not capture parent stdout/stderr, so those fields are None. Terraform state and outputs are not automatically attached.

Outside hooks, ctx.hook and ctx.operation are None. A hook without an individual component binding, including aggregate hooks, has ctx.component == None.

For example, configure a reusable smoke test through inherited settings:

settings:
post_apply:
enabled: true
command: ["./smoke-test", "--strict"]

hooks:
smoke-test:
events: [after.terraform.apply]
kind: step
type: script
on_failure: fail
with:
interpreter: starlark
script: !include scripts/post-apply.star
scripts/post-apply.star
def post_apply():
policy = ctx.component.settings.get("post_apply", {})
if not policy.get("enabled", False):
return
if ctx.operation.status != "success":
return
ctx.component.exec(policy.get("command", ["./smoke-test"]))
ui.success("Smoke tests passed.")

post_apply()

The same context is available when a hook runs Starlark checks through a type: test step:

hooks:
verify:
events: [after.terraform.apply]
kind: step
type: test
on_failure: fail
with:
steps:
- name: component is tagged
type: script
interpreter: starlark
script: |
if "owner" not in ctx.component.vars.get("tags", {}):
fail("missing owner tag on " + ctx.component.name)

Secrets, stores, and error reporting​

Direct Starlark APIs for secret access, store reads/writes, and Terraform state/output reads are not available yet. Scripts can consume values resolved by existing YAML functions through explicit inputs or component configuration.

Use errors.build to create script errors with the same explanations, hints, examples, context, and exit codes as Atmos's own errors. Chain the builder methods and call .fail() to raise the error.

Unhandled Starlark errors propagate through Atmos's normal command error path, including Sentry capture when configured. Handled failures and retries that eventually succeed do not produce a final parent-command error. An Atmos subprocess can still report its own errors independently. Dedicated Starlark stack frames and parallel-task metadata are not yet mapped into Sentry events.