Ops
Deciding whether Ops is the right deployment model for your project? See Choosing Your Deployment Model.
Ops (chant’s named-workflow abstraction, not “operations” in general) are named, phased Temporal workflows defined in *.op.ts files. They turn ad-hoc deployment scripts into durable, observable workflows: progress surfaces in the Temporal UI, gate steps can pause for human approval or external events, and onFailure phases handle compensation automatically.
When to reach for an Op
Section titled “When to reach for an Op”- Long-running deploys — see current phase without grepping logs.
- Gates — pause for human approval or an external-system event (DNS delegation, change windows).
- Automatic rollback —
onFailurephases compensate on failure instead of hand-rolled scripts.
chant run <name> runs an Op locally by default — in-process, with no Temporal server. chant build compiles each *.op.ts into generated Temporal worker code for when you run with --temporal, which spawns the worker and submits the workflow. See Local vs Temporal for the trade-off.
Temporal-native when you want durability, zero-dependency when you don’t: the local executor is the on-ramp, and the synthesis primitives below it need no executor at all. Reach for Temporal when an Op must survive a crash, hold a long approval, or roll back a partial failure.
Defining an Op
Section titled “Defining an Op”Create a *.op.ts file anywhere in your project:
import { Op, phase, kubectlApply, shell } from "@intentius/chant-lexicon-temporal";
export default Op({ name: "alb-deploy", overview: "Deploy the ALB infra then the application layer", phases: [ phase("Infra", [ kubectlApply("dist/infra.yaml", { profile: "longInfra" }), ]), phase("App", [ kubectlApply("dist/k8s.yaml"), shell("kubectl rollout status deployment/api"), ]), ],});Try it locally
Section titled “Try it locally”Before reaching for real infrastructure, verify your toolchain with a shell-only Op. Save as hello.op.ts anywhere in your project:
import { Op, phase, shell } from "@intentius/chant-lexicon-temporal";
export default Op({ name: "hello", overview: "Smallest possible Op — one shell step", phases: [ phase("Greet", [shell("echo hello from chant")]), ],});In one terminal, start a local Temporal dev server:
temporal server start-devIn another, build and run:
chant buildchant run helloOnce this works end-to-end, swap shell() for kubectlApply(), helmInstall(), or any other step builder to start deploying real resources.
Phases and steps
Section titled “Phases and steps”Each phase() has a name and an ordered list of steps. All steps in a phase run sequentially by default. Set parallel: true to run them concurrently via Promise.all.
Pre-built step builders:
| Builder | What it does |
|---|---|
shell(cmd, opts?) | Run an arbitrary shell command |
build(path) | Run chant build |
kubectlApply(manifest, opts?) | kubectl apply -f |
helmInstall(name, chart, opts?) | helm upgrade --install |
waitForStack(stackFile, opts?) | Poll until a chant stack output file is ready |
gitlabPipeline(projectId, ref, opts?) | Trigger a GitLab pipeline and wait |
lifecycleSnapshot(env, opts?) | chant lifecycle snapshot |
teardown(path, opts?) | Build + destroy |
A reconcile activity is also available via activity("reconcilePr", args): given the change-set entries that triggered it, it regenerates TypeScript with chant import --from <env> and opens a reviewable PR (modes: pull-request, issue, report). It never commits to the main branch. It is the building block for the reconcile workflow (cloud → code).
Each step takes an optional profile that controls Temporal retry and timeout settings:
| Profile | Suitable for |
|---|---|
fastIdempotent (default) | Quick, safe-to-retry steps |
longInfra | Slow infra changes (cluster create, Helm install) |
k8sWait | Polling until K8s resources are ready |
humanGate | Steps that may take hours |
Gate steps
Section titled “Gate steps”A gate pauses the workflow until a named signal is received. Typical gate scenarios:
- Human approval inside a change window
- Manual QA sign-off before promoting to prod
- Upstream ticket or external-team handoff
- External system readiness (DNS delegation, TLS cert issuance, vendor provisioning)
The example below waits for DNS delegation to complete before applying the app layer:
import { Op, phase, gate, kubectlApply } from "@intentius/chant-lexicon-temporal";
export default Op({ name: "dns-delegation", overview: "Deploy, then wait for DNS delegation before continuing", phases: [ phase("Deploy", [kubectlApply("dist/infra.yaml")]), phase("Await DNS", [gate("gate-dns-delegation", "72h")]), phase("Post-delegation", [kubectlApply("dist/k8s.yaml")]), ],});Send the signal when the external action completes:
chant run signal dns-delegation gate-dns-delegationCompensation on failure
Section titled “Compensation on failure”onFailure phases run in order if the workflow terminates with an unhandled error:
export default Op({ name: "alb-deploy", phases: [ phase("Infra", [kubectlApply("dist/infra.yaml", { profile: "longInfra" })]), phase("App", [kubectlApply("dist/k8s.yaml")]), ], onFailure: [ phase("Rollback", [shell("kubectl delete -f dist/infra.yaml --ignore-not-found")]), ],});Search attributes
Section titled “Search attributes”Each generated workflow auto-emits upsertSearchAttributes() calls so workflow runs are filterable in the Temporal UI without hand-coded boilerplate. Two emission points:
- Initial call at workflow start:
OpNameplus anysearchAttributesyou declare on the Op - Per-phase call at the start of each phase (and each
onFailurephase):Phaseset to the current phase name
Declare custom attributes on the Op:
export default Op({ name: "alb-deploy", overview: "Deploy ALB stack", searchAttributes: { Environment: "staging", Region: "us-east-1", }, phases: [ phase("Build", [/* ... */]), phase("Deploy", [/* ... */]), ],});The generated workflow.ts produces:
export async function albDeployWorkflow(): Promise<void> { upsertSearchAttributes({ OpName: ["alb-deploy"], Environment: ["staging"], Region: ["us-east-1"], });
// Phase: Build upsertSearchAttributes({ Phase: ["Build"] }); // ...
// Phase: Deploy upsertSearchAttributes({ Phase: ["Deploy"] }); // ...}For an Op with N phases this is N+1 upsert calls total. Values are wrapped as single-element arrays for the classic @temporalio/workflow API.
Registration is separate. Auto-emit assumes the attributes are already registered server-side. Declare them with the
SearchAttributeresource sochant buildemits the registration commands. See alsochant lifecyclefor snapshotting registered attributes against a live cluster.
User-provided keys merge over the OpName default; if you set searchAttributes: { OpName: "custom" } the user value wins.
Outcome-based search attributes
Section titled “Outcome-based search attributes”Activity steps support an optional outcomeAttribute field that captures the activity’s return value and surfaces it as a workflow search attribute:
phase("Diff", [ { kind: "activity", fn: "lifecycleDiff", args: { env: "prod", live: true }, // Capture lifecycleDiff's `drifted` field and tag the run Drift=true/false outcomeAttribute: { name: "Drift", from: "drifted" }, },]),The serializer turns this into:
const __r0 = await lifecycleDiff({"env":"prod","live":true});upsertSearchAttributes({ "Drift": [String(__r0?.drifted)] });from is a dot-path into the return value; when omitted, the whole return value is stringified. Counters are workflow-scoped (__r0, __r1, …), and parallel phases destructure Promise.all results so each outcome attribute fires after the corresponding activity returns.
Continuous observation
Section titled “Continuous observation”The WatchOp composite pairs an Op with a TemporalSchedule so chant lifecycle snapshot and chant lifecycle diff --live run on a recurring cron — periodic drift detection between change windows, not just at the next deploy.
See Watching Lifecycle for the full walk-through: composite shape, generated workflow, Temporal UI filters (Watch = "true" / Drift = "true"), smoke-testing drift end-to-end, and how to triage each diff category. For the conceptual background on what drift means and why chant snapshots are observational, see Drift Detection.
Reconcile (cloud → code)
Section titled “Reconcile (cloud → code)”The ReconcileOp composite takes the next step on the dial: when live drifts from declarations, open a PR that regenerates the affected TypeScript. Phases are snapshot → plan → regenerate → open PR — the plan is chant lifecycle plan, and the regenerate-and-PR step is the reconcilePr activity.
import { ReconcileOp } from "@intentius/chant-lexicon-temporal";
// Scheduled on Temporal, owned-only. Omit `schedule` for a one-shot// `chant run prod-reconcile` on the local executor.const { op, schedule } = ReconcileOp({ name: "prod-reconcile", env: "prod", schedule: "0 * * * *", // hourly scope: { owned: true }, onDrift: "pull-request", // or "issue" | "report"});
export default op; // the Op — discovered by `chant run prod-reconcile`export { schedule }; // the TemporalSchedule — deployed by `chant build`Run chant run prod-reconcile for a one-shot reconcile on the local executor; give it a schedule to run continuously on Temporal, where the cron and run history are the value (as with WatchOp). The primitives need no executor — only the scheduled, durable form requires Temporal.
Apply (code → cloud)
Section titled “Apply (code → cloud)”The ApplyOp composite is the other direction: compute the plan, then apply via the target’s native mechanism — kubectl apply, CloudFormation deploy, or an ARM deployment. Authority stays with the platform; chant hosts no state file.
import { ApplyOp } from "@intentius/chant-lexicon-temporal";
// Gated destructive apply on Temporal. Drop `delete`/`gate` for an additive// apply that also runs one-shot on the local executor.const { op } = ApplyOp({ name: "prod-apply", env: "prod", target: "kubectl", delete: "gated", // "never" | "owned-only" | "gated" gate: { signalName: "approve-apply", description: "Approve prod apply with deletes" },});
export default op;delete controls how apply treats resources no longer declared. Deletes ride the target’s native delete path scoped to the ownership marker — kubectl apply --prune --selector app.kubernetes.io/managed-by=chant, the CloudFormation stack boundary, or ARM --mode Complete. Because the prune is marker-scoped, a foreign (unmarked) resource is never touched: deletes are owned-only by construction.
Gates and compensation — where Temporal is load-bearing
Section titled “Gates and compensation — where Temporal is load-bearing”A destructive apply is exactly where durability matters, and where ApplyOp leans on Temporal:
- Approval gate —
delete: "gated"(or an explicitgate) inserts an Approve phase before the apply. On Temporal this is a durable wait-for-signal: it survives a worker crash and can hold for hours or days without keeping a process open. The local executor cannot. - Compensation — a destructive apply defaults to an
onFailureRollback phase (acompensateApplyactivity), so a partial failure unwinds instead of leaving the cloud half-applied. CloudFormation rolls back natively; for kubectl/ARM, passcompensate: { command }(e.g.kubectl rollout undo …) — without one, compensation warns rather than silently doing nothing. Setcompensate: falseto opt out. - Crash-resume + audit — the workflow resumes from the last completed step with no double-acting, and the workflow history is the audit trail.
An ungated, additive apply needs none of this and runs on the local executor. See Durable Workflows for why infra apply wants durable execution — and why it stays optional.
Op dependencies
Section titled “Op dependencies”depends declares Ops that must succeed before this one starts. chant build validates all referenced names exist at build time.
export default Op({ name: "app-deploy", depends: ["infra-bootstrap"], // ...});View the dependency graph:
chant graphRunning Ops
Section titled “Running Ops”# Start an Op (spawns Temporal worker + submits workflow)chant run alb-deploy
# Use a specific connection profile from chant.config.tschant run alb-deploy --profile cloud
# List all Ops with latest run statuschant run list
# Show current workflow statechant run status alb-deploy
# Unblock a gate stepchant run signal alb-deploy gate-dns-delegation
# Cancel an active runchant run cancel alb-deploy --force
# View run historychant run log alb-deploySee chant run for the full CLI reference, and Worker Profiles for connection configuration.
Codegen
Section titled “Codegen”chant build emits three files per Op under dist/ops/<name>/:
| File | Purpose |
|---|---|
workflow.ts | Temporal workflow function — phases, gates, onFailure |
activities.ts | Re-exports all pre-built activity implementations |
worker.ts | Worker bootstrap — reads profile from chant.config.js |