# Cpf Workflow Author

> Create and edit checkpointflow workflow YAML files. Use this skill whenever the user wants to create a workflow, automate a process as a YAML pipeline, define steps that mix CLI commands with human or agent checkpoints, turn a conversation into a reusable runbook, or asks about writing cpf/checkpointflow workflows. Also use it when you see keywords like "workflow", "runbook", "approval flow", "pause and resume", or "await event" in the context of automation.

- Skill: `thomasrohde/cpf-workflow-author` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add thomasrohde/cpf-workflow-author`
- Raw SKILL.md: https://api.skillmd.com/api/skills/thomasrohde/cpf-workflow-author/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: thomasrohde (https://skillmd.com/u/thomasrohde)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/thomasrohde/cpf-workflow-author

---


# cpf-workflow-author

Create checkpointflow workflow YAML files that are valid, idiomatic, and ready to run with `cpf run`.

## Before you start

Read the checkpointflow guide to get the full reference:
```bash
cpf guide
```

If you need to see existing examples in the project, check `examples/*.yaml` in the checkpointflow repo.

## Authoring process

1. **Understand what the user wants to automate.** Ask clarifying questions if needed: What commands or tools are involved? Where does a human or agent need to weigh in? What are the inputs?

2. **Design the step sequence.** Map each action to a step kind:
   - A shell command or script invocation -> `cli`
   - A point where execution must pause for human/agent input -> `await_event`
   - A terminal outcome -> `end`

3. **Decide where to put the file.** If the user specifies a path, use it. Otherwise, prompt the user to choose:
   - **Local** (`.checkpointflow/<id>.yaml`) — scoped to the current project, checked into the repo
   - **Global** (`~/.checkpointflow/<id>.yaml`) — available everywhere via `cpf flows`
   - **Examples** (`examples/<id>.yaml`) — only offer this if inside the checkpointflow repo itself

   Skip the question when the location is obvious from context (e.g. "add a global workflow", "create a workflow in this repo", or an explicit file path).

4. **Validate immediately** after writing:
   ```bash
   cpf validate -f <path-to-workflow.yaml>
   ```
   If validation fails, fix the YAML and re-validate before telling the user the workflow is ready.

5. **Offer to run it** if the user seems ready. When the workflow hits an `await_event` step (exit code 40), collect the required input via a structured prompt, then `cpf resume` to continue.

## Workflow file structure

Every workflow file follows this skeleton:

```yaml
schema_version: checkpointflow/v1
workflow:
  id: snake_case_identifier
  name: Human Readable Name
  version: 0.1.0
  description: >
    A clear description of what this workflow does, why it exists,
    and what the user/agent will experience when running it.

  inputs:
    type: object
    required: [field_name]
    properties:
      field_name:
        type: string
        description: What this field is for and how it will be used
        examples: ["realistic-value-1", "realistic-value-2"]

  steps:
    - id: first_step
      kind: cli
      command: echo "doing something with ${inputs.field_name}"

    - id: done
      kind: end
      result:
        status: completed
```

Key rules:
- `schema_version` is always `checkpointflow/v1`
- `workflow.id` must be present, snake_case, and unique
- `workflow.inputs` uses JSON Schema to define what the caller must provide
- Every step needs a unique `id`
- Steps execute sequentially unless a transition jumps elsewhere

### Writing great names and descriptions

The `name` and `description` fields are emitted in every JSON envelope at runtime. When an agent runs or resumes this workflow, these are the **only context it has** about what the workflow does — it may not have read the YAML file. Write them as if they are the briefing for an agent that knows nothing else.

**`name`** — a short, specific title that tells the agent what process it is driving. Avoid generic names like "Build Pipeline" or "Review Flow". Instead, be specific: "PR Review with Security Gate", "TDD Feature Development for CPF".

**`description`** — a 1-3 sentence explanation that covers:
1. What the workflow does end-to-end (the happy path)
2. Who is involved (human approvals? agent steps? parallel checks?)
3. What methodology or constraints it follows (e.g. "TDD: tests first, then implementation")

Bad: `description: A workflow for deployments.`
Good: `description: >
  Deploys a service to production with a two-phase rollout. An agent runs
  pre-deploy checks (lint, test, canary), then pauses for human approval
  before promoting to full traffic. Rolls back automatically on health check failure.`

### Writing great input properties

Input `description` and `examples` directly shape the UX when an agent or skill collects input from the user. The runner skill uses `examples` as selectable options in its prompt UI — without them, the agent has to guess what values look like.

**Always include `examples`** on string inputs that don't have an `enum`. Provide 2-3 realistic values that illustrate the expected format:

```yaml
properties:
  feature_name:
    type: string
    description: Short kebab-case name for the feature
    examples: ["run-delete", "gui-filter", "batch-resume"]
  target_url:
    type: string
    description: The URL to deploy to
    examples: ["https://staging.example.com", "https://prod.example.com"]
```

**`description`** on input properties should say what the value is *for*, not just what type it is. "The environment name" is worse than "Target deployment environment — determines which config and secrets are loaded."

For `enum` inputs, `examples` are unnecessary — the enum values themselves become the options. But write a clear `description` explaining what each choice means if it's not obvious from the value names.

## Step kinds

All step kinds below are fully supported at runtime.

### cli

Runs a shell command. Supports `${inputs.x}` and `${steps.<id>.outputs.x}` interpolation. Use `cli` for steps that run real shell commands. Do NOT use `cli` with `echo` as a placeholder for agent work — use `await_event` with `audience: agent` instead.

`command` accepts a string or a list of strings. Lists are joined with `&&` at runtime — use lists to avoid YAML multi-line scalar pitfalls:

```yaml
- id: build
  kind: cli
  cwd: ${inputs.project_dir}
  command:
    - npm run lint
    - npm run build --project ${inputs.project_name}
    - npm test
  timeout_seconds: 120
```

Use `cwd` to set the working directory (supports interpolation). Use `shell` to pick a specific shell (`bash`, `sh`, `powershell`, `pwsh`), or set `defaults.shell` at the workflow level:

```yaml
workflow:
  defaults:
    shell: bash
```

If the command produces structured JSON on stdout, declare `outputs` so the runtime validates it and makes it available to later steps:

```yaml
- id: analyze
  kind: cli
  command: analyze-tool --format json ${inputs.target}
  outputs:
    type: object
    required: [score, report_path]
    properties:
      score: { type: number }
      report_path: { type: string }
```

Use `if` for conditional execution (no `${}` wrapper needed):

```yaml
- id: deep_scan
  kind: cli
  command: scan --deep ${inputs.target}
  if: inputs.mode == "full"
```

### await_event

Pauses the workflow and waits for external input. The CLI exits with code 40 and emits a waiting envelope that tells the caller exactly what input is needed and how to resume.

**`audience: user`** — The step pauses for a human to make a decision. The `prompt` presents context and choices. The `input_schema` defines what the user should provide.

```yaml
- id: review
  kind: await_event
  audience: user
  event_name: review_decision
  prompt: Review the results and decide whether to proceed.
  input_schema:
    type: object
    required: [decision]
    properties:
      decision:
        type: string
        enum: [approve, reject]
      notes:
        type: string
```

**`audience: agent`** — The step pauses for an AI agent to do real work. The `prompt` is the work assignment — describe what to analyze, build, or run. The `input_schema` defines the structured output the agent must deliver. The workflow pauses, the agent does the work, and resumes with results. Use this instead of `cli` with `echo` for agent work.

```yaml
- id: analyze
  kind: await_event
  audience: agent
  event_name: analysis_results
  name: "Agent: Analyze Codebase"
  prompt: |
    Scan src/${inputs.area}/ and identify:
    - Source files and their responsibilities
    - Existing test files
    - Key dependencies
  input_schema:
    type: object
    required: [files_found, notes]
    properties:
      files_found: { type: array, items: { type: string } }
      test_files: { type: array, items: { type: string } }
      notes: { type: string }
```

Add `transitions` to branch based on the input received:

```yaml
  transitions:
    - when: ${event.decision == "approve"}
      next: deploy
    - when: ${event.decision == "reject"}
      next: cancelled
```

Without transitions, execution continues to the next step in the array.

### end

Terminates the workflow with an explicit result. The result can interpolate values from earlier steps:

```yaml
- id: done
  kind: end
  result:
    status: success
    url: ${steps.deploy.outputs.url}
```

If no `end` step is present, the workflow completes implicitly after the last step with no result. Use `end` when you want to return meaningful data or when you have multiple terminal states (e.g., approved vs. rejected).

### switch

Evaluates conditions in order and jumps to the first matching step. Use for data-driven branching without human input.

```yaml
- id: route
  kind: switch
  cases:
    - when: 'inputs.priority == "critical"'
      next: fast_track
    - when: 'inputs.priority == "low"'
      next: backlog
  default: standard_review
```

Each case has `when` (expression) and `next` (target step ID). `default` is optional — if no case matches and no default, the step fails.

### api

Makes an HTTP request. Response body is parsed as JSON and exposed as step outputs.

```yaml
- id: fetch_status
  kind: api
  method: GET
  url: ${inputs.api_base}/health
  headers:
    Authorization: Bearer ${inputs.token}
```

Optional: `body` (sent as JSON), `headers`, `success.status_codes` (default: `[200, 201, 204]`), `timeout_seconds` (default: 30).

### foreach

Iterates over a list. Within body steps, `${item}` is the current element and `${item_index}` is the zero-based index.

```yaml
- id: process_files
  kind: foreach
  items: inputs.files
  body:
    - id: convert
      kind: cli
      command: convert ${item} --output out_${item_index}.pdf
```

Body steps currently support `cli` and `end` kinds.

### parallel

Runs multiple steps concurrently. Each branch references a step ID via `start_at`. All branches must succeed.

```yaml
- id: checks
  kind: parallel
  branches:
    - start_at: lint
    - start_at: test
    - start_at: typecheck
```

Branch target steps must be `cli` or `end` steps in the same workflow.

### workflow

Invokes another workflow YAML as a sub-workflow.

```yaml
- id: deploy
  kind: workflow
  workflow_ref: ./deploy.yaml
  inputs:
    version: ${steps.build.outputs.version}
```

Sub-workflow steps currently support `cli` and `end` kinds.

## Expressions

Expressions use `${...}` syntax for interpolation in `command` strings and transition `when` clauses:
- `${inputs.name}` — workflow input
- `${steps.build.outputs.artifact}` — output from a previous step
- `${event.decision}` — event data (only in `when` clauses)

In `if` conditions, write the expression bare (no `${}`):
- `if: inputs.mode == "full"`

Operators: `==`, `!=`, `and`, `or`

## Branching patterns

**Skip a step conditionally** — use `if`:
```yaml
- id: optional_step
  kind: cli
  command: do-extra-work
  if: inputs.thorough == "yes"
```

**Branch on data** — use `switch` for data-driven branching without human input:
```yaml
- id: route
  kind: switch
  cases:
    - when: 'steps.check.outputs.count == 0'
      next: nothing_to_do
  default: process_items
```

**Branch after human input** — use `transitions` on `await_event` to jump to different steps based on the response. Make sure every transition target step exists in the steps array:
```yaml
- id: approval
  kind: await_event
  audience: user
  event_name: approval
  prompt: Approve or reject?
  input_schema:
    type: object
    required: [choice]
    properties:
      choice: { type: string, enum: [approve, reject] }
  transitions:
    - when: ${event.choice == "approve"}
      next: do_work
    - when: ${event.choice == "reject"}
      next: rejected

- id: do_work
  kind: cli
  command: echo "approved"

- id: complete
  kind: end
  result: { outcome: approved }

- id: rejected
  kind: end
  result: { outcome: rejected }
```

**Cover every enum value with a transition** — once an `await_event` has `transitions`, the runtime does NOT fall through to the next step. If no transition matches, the run **fails** with "No transition matched." Every possible enum value in the `input_schema` must have a corresponding `when` clause:

```yaml
# WRONG — "ship_it" has no transition, run will fail
input_schema:
  properties:
    decision: { type: string, enum: [ship_it, revise, shelve] }
transitions:
  - when: ${event.decision == "revise"}
    next: revision
  - when: ${event.decision == "shelve"}
    next: shelved
  # Missing: ship_it → approved

# CORRECT — every enum value is covered
transitions:
  - when: ${event.decision == "ship_it"}
    next: approved
  - when: ${event.decision == "revise"}
    next: revision
  - when: ${event.decision == "shelve"}
    next: shelved
```

The same rule applies to `switch` cases — if no case matches and no `default` is set, the run fails.

Be aware that steps between a transition target and the next step will execute sequentially. If `do_work` transitions to step `complete`, but `rejected` comes after `complete` in the array, the `rejected` step will NOT be reached from the `do_work` path — execution flows forward from `do_work` through `complete` and stops at the `end`. Place your branch targets so that sequential fall-through does not hit steps from other branches, or use `end` steps to terminate each branch.

## Common mistakes to avoid

- **Missing `schema_version: checkpointflow/v1`** at the top level — every file needs it.
- **Duplicate step IDs** — each step `id` must be unique across the entire workflow.
- **Transition target doesn't exist** — every `next` value must match an actual step `id`.
- **Using `${}` in `if` conditions** — `if` takes a bare expression, not wrapped in `${}`.
- **Forgetting `input_schema` on `await_event`** — it's required, not optional.
- **Incomplete transition coverage** — when `await_event` has `transitions`, every possible `input_schema` enum value must have a matching `when` clause. Uncovered values cause a runtime failure, not a fallthrough.
- **Using `cli` with `echo` for agent work** — use `await_event` with `audience: agent` instead. The `prompt` describes the work, and `input_schema` defines the expected output.
- **Step kind typos** — supported kinds are `cli`, `await_event`, `end`, `switch`, `api`, `foreach`, `parallel`, and `workflow`. Any other kind fails at runtime with exit code 80.

## After writing the workflow

Always validate:
```bash
cpf validate -f <path>
```

If the user wants to run it:
```bash
cpf run -f <path> --input '{"key": "value"}'
```

When a run pauses at an `await_event` (exit code 40), the JSON envelope contains a `wait` block with the prompt, input schema, and resume command. Gather the input from the user via a structured prompt, then resume:
```bash
cpf resume --run-id <id> --event <event_name> --input '{"key": "value"}'
```

