Skip to content
Back to skills

Workflow

ASecurity

Write an Ironflow WorkflowHandler - typed input schema, steps (shell, http, agent, approval, human input, signal, decision, sub-workflow, parallel), registration in handlers(). Loaded by the ironflow hub for the workflow verb.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 6, 2026
toolsrustgoshellbashgitapi

Works with

  • claude code
  • cli
  • api

Security analysis

A100/100

Pro scans all 3 files and shows the line behind each finding

Scanned October 6, 2026

npx -y skills add ThomasTartrau/ironflow --skill workflow --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Workflow?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Workflow
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/thomastartrau-workflow/badge)](https://www.skillsdirectory.com/skills/thomastartrau-workflow)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: workflow
description: Write an Ironflow WorkflowHandler - typed input schema, steps (shell, http, agent, approval, human input, signal, decision, sub-workflow, parallel), registration in handlers(). Loaded by the ironflow hub for the workflow verb.
user-invocable: false
---

# Ironflow workflow

A workflow is a struct implementing `WorkflowHandler`. Control flow is plain Rust. Each `ctx.*` call is a persisted step with its own status, duration and cost.

## 1. Gather the shape

Ask only what the code cannot guess, in one AskUserQuestion call:

- workflow name (kebab-case, unique) and one-line purpose
- input fields (name, type, required or default)
- the steps in order, and which of them run in parallel
- whether a human approval gate sits somewhere, and whether an agent step needs a budget above `0.10` USD

Then locate the workflows crate: `grep -rl "pub fn handlers" --include=*.rs .`

## 2. Write the handler

One file per handler in the workflows crate, `snake_case.rs`. Template:

```rust
use ironflow_engine::config::{ApprovalConfig, ShellConfig, StepConfig};
use ironflow_engine::context::WorkflowContext;
use ironflow_engine::handler::{HandlerFuture, WorkflowHandler, input_schema_for};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::Value;

/// Input payload of the `deploy` workflow. The dashboard renders a form from it.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct DeployInput {
    /// Git ref to deploy.
    pub git_ref: String,
    /// Target environment.
    #[serde(default = "default_env")]
    pub environment: String,
}

fn default_env() -> String {
    "staging".to_string()
}

/// Builds, tests in parallel, waits for a human, deploys.
pub struct Deploy;

impl WorkflowHandler for Deploy {
    fn name(&self) -> &str {
        "deploy"
    }

    fn description(&self) -> &str {
        "Build, run checks in parallel, wait for approval, deploy."
    }

    fn category(&self) -> Option<&str> {
        Some("ops")
    }

    fn input_schema(&self) -> Option<Value> {
        Some(input_schema_for::<DeployInput>())
    }

    fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
        Box::pin(async move {
            let input: DeployInput = ctx.input().await?;

            ctx.shell(
                "build",
                ShellConfig::new("cargo build --release").env("GIT_REF", &input.git_ref),
            )
            .await?;

            // Fan out. `true` = fail fast on the first error.
            let checks = ctx
                .parallel(
                    vec![
                        ("test", StepConfig::Shell(ShellConfig::new("cargo test"))),
                        ("lint", StepConfig::Shell(ShellConfig::new("cargo clippy"))),
                    ],
                    true,
                )
                .await?;
            if !checks.iter().all(|c| c.output.is_success()) {
                return Ok(());
            }

            if ctx
                .when("production run", |i: &DeployInput| i.environment == "production")
                .await?
            {
                // The run suspends here. After approval the handler is replayed from
                // the top: completed steps come back from cache, this code runs again.
                ctx.approval(
                    "approve-production",
                    ApprovalConfig::new("Ship to production?").with_timeout_seconds(3600),
                )
                .await?;
            }

            ctx.shell(
                "deploy",
                ShellConfig::new("./deploy.sh \"$ENVIRONMENT\"").env("ENVIRONMENT", &input.environment),
            )
            .await?;

            Ok(())
        })
    }
}
```

Then add the source display, which needs the real file name:

```rust,ignore
    fn source_code(&self) -> Option<&str> {
        Some(include_str!("deploy.rs"))
    }
```

Step catalogue with every config builder: `references/steps.md`. Read it before writing an agent, http, signal (`ctx.wait_for_signal`), sub-workflow, secret or artifact step.

## 3. Register

In the workflows crate `lib.rs`: `mod deploy;`, `pub use deploy::{Deploy, DeployInput};`, and one line in `handlers()`:

```rust,ignore
pub fn handlers() -> Vec<Box<dyn WorkflowHandler>> {
    vec![Box::new(Hello), Box::new(Deploy)]
}
```

That list feeds both the server and the worker. Nothing else to touch.

## 4. Verify

```bash
cargo build -p workflows
cargo test -p workflows
```

Then offer, in one sentence, the workflow reviewer (`/ironflow review`) and the test skill (`/ironflow test <name>`). Do not run either without an answer.

## Rules that are not obvious

- **Branches are invisible to the planner unless you declare them.** A plain `if` works, but `ctx.when("production run", |i: &DeployInput| i.environment == "production").await?` and `ctx.when_dynamic("build succeeded", build.is_success())` make it show up in `ironflow run plan <name>`, which lists the steps a run would create without executing anything. The first argument is a label, never parsed; the closure gets the typed input. See `references/steps.md`.
- **No stringly-typed access.** Read step outputs with `stdout()`, `stderr()`, `exit_code()`, `status()`, `body()`, `error()` (the message of an `allow_failure` step that failed), the input with `ctx.input::<T>()`, decisions through a `#[derive(DecisionAnswers)]` struct, agent answers through `.output::<T>()`, and artifacts through the handle `step.artifact("file")?`. Never index the raw output JSON by key, and never copy a step name by hand where a handle exists. Sub-workflow children implement `TypedWorkflow`; `ctx.workflow(&Child, ChildInput { .. })` returns `run_id()` as a `Uuid`.
- **Step names are cache keys.** Stable, unique within the run, no timestamps or random ids. In a loop, suffix with the loop index. `ctx.parallel` enforces it for its branches: a duplicate name fails the run before any step starts.
- **Approval replays the handler.** Anything that is not a `ctx.*` step runs again after approval. Keep side effects inside steps. Details and a safe pattern: `references/approval-replay.md`.
- **Agent steps: tools or structured output, not both.** `AgentStepConfig::new(prompt).allow_tool(Tool::Read)` and `.output::<T>()` are mutually exclusive by type. With `.output::<T>()`, `ctx.agent` returns the `T` itself. On an HTTP provider, `.tool_profile(BUG)` (a `ToolProfile` constant shared with the worker) picks a named tool profile registered on the worker (`references/steps.md`); it counts as tools too, and a Claude CLI provider refuses it.
- **Budget.** Claude Code's system cache alone costs about `0.04` USD, so `max_budget_usd` below `0.10` fails. Put a `default_max_cost_usd` on the handler when it contains an agent step.
- **User data goes through `env`, never into the command string.** `ShellConfig::new("echo \"$X\"").env("X", value)`.
- **A failing step fails the run.** No hidden retries. `allow_failure()` on a step config lets the run continue with status `Warning`; `retry_policy(RetryPolicy::...)` opts into retries explicitly. On a shell step, `exit_code_as_output()` makes a non-zero exit a normal output (`exit_code()`, `is_success()`) instead of a failure.
- **`ctx.input::<T>()` errors are `EngineError::Serialization`.** The engine also validates the payload against `input_schema()` before creating a run through the API, so the handler rarely sees a bad payload.

Files in this skill

  • SKILL.md7.3 KB
  • references/approval-replay.md4.2 KB
  • references/steps.md35.2 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…