Skip to content

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.

  • Long-running deploys — see current phase without grepping logs.
  • Gates — pause for human approval or an external-system event (DNS delegation, change windows).
  • Automatic rollbackonFailure phases 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.

Op execution model: sequential phases, parallel phases, gate steps, and onFailure compensation
Op execution model — phases, gates, and compensation

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"),
]),
],
});

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:

Terminal window
temporal server start-dev

In another, build and run:

Terminal window
chant build
chant run hello

Once this works end-to-end, swap shell() for kubectlApply(), helmInstall(), or any other step builder to start deploying real resources.

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:

BuilderWhat 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:

ProfileSuitable for
fastIdempotent (default)Quick, safe-to-retry steps
longInfraSlow infra changes (cluster create, Helm install)
k8sWaitPolling until K8s resources are ready
humanGateSteps that may take hours

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:

Terminal window
chant run signal dns-delegation gate-dns-delegation

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")]),
],
});

Each generated workflow auto-emits upsertSearchAttributes() calls so workflow runs are filterable in the Temporal UI without hand-coded boilerplate. Two emission points:

  1. Initial call at workflow start: OpName plus any searchAttributes you declare on the Op
  2. Per-phase call at the start of each phase (and each onFailure phase): Phase set 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 SearchAttribute resource so chant build emits the registration commands. See also chant lifecycle for 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.

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.

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.

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.

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 gatedelete: "gated" (or an explicit gate) 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 onFailure Rollback phase (a compensateApply activity), so a partial failure unwinds instead of leaving the cloud half-applied. CloudFormation rolls back natively; for kubectl/ARM, pass compensate: { command } (e.g. kubectl rollout undo …) — without one, compensation warns rather than silently doing nothing. Set compensate: false to 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.

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:

Terminal window
chant graph
Terminal window
# Start an Op (spawns Temporal worker + submits workflow)
chant run alb-deploy
# Use a specific connection profile from chant.config.ts
chant run alb-deploy --profile cloud
# List all Ops with latest run status
chant run list
# Show current workflow state
chant run status alb-deploy
# Unblock a gate step
chant run signal alb-deploy gate-dns-delegation
# Cancel an active run
chant run cancel alb-deploy --force
# View run history
chant run log alb-deploy

See chant run for the full CLI reference, and Worker Profiles for connection configuration.

chant build emits three files per Op under dist/ops/<name>/:

FilePurpose
workflow.tsTemporal workflow function — phases, gates, onFailure
activities.tsRe-exports all pre-built activity implementations
worker.tsWorker bootstrap — reads profile from chant.config.js