From One Sentence to a Runnable Workflow: A Refund Triage Spec

Most automation projects do not fail at the code. They fail at the sentence. “Handle refund emails for me” is a wish with a subject line attached, and no amount of tooling turns it into something you can debug on a Tuesday afternoon.

Below is the conversion, done in five passes: trigger, decomposition, types, validation, and a single manual run. The example is refund triage, but the passes are identical whether you are routing leads, summarizing tickets, or watching a pricing page.

Pass 1: Fix the trigger before anything else

The trigger is the only part of a workflow you do not control, so it should be the dumbest part. Answer four questions: what event, from what source, what does the payload look like, and how often can it fire?

“Emails about refunds” is not a trigger. It is a trigger plus a classifier glued together. The test is simple: can you reproduce the trigger without running a model? If not, you can never tell whether a misfire came from the trigger or from the classifier.

Prefer a dedicated inbox, a form, a table row, or a webhook. Classification belongs in step one, where you can log its input alongside its output.

TriggerFires onReplayable by handFailure visibility
Dedicated inboxMessage arrivalYes, replay a saved .emlHigh: the message just sits there
Shared inbox + labelA human applies a labelYesMedium: depends on label discipline
Classifier over all mailA model decisionNoLow: misfires look like silence
Webhook formHTTP POSTYes, one curlHigh: a non-200 is logged

Chosen: new unread message in the support mailbox. Nothing else fires the workflow.

Pass 2: Decompose into one verb per step

Rewrite the sentence as a sequence where each step does one thing and can be tested alone.

  1. Classify intent.
  2. Extract the order ID.
  3. Fetch the order.
  4. Apply the refund policy.
  5. Draft a reply.
  6. Route for human approval.

Six steps, seven with the trigger. Small enough to hold in your head, large enough to be worth automating.

Two failure modes appear at this stage. Over-decomposition splits “fetch the order” into parse ID, build URL, call API, parse response, which gives you four steps you will never test individually. Under-decomposition collapses “handle the refund” into one step, so a policy rejection and a network timeout land in the same bucket.

Write the spec down before you build anything. YAML is fine. The point is that it is reviewable and versionable.

name: refund-triage
trigger:
  type: imap.new_message
  mailbox: support@
  filter: { unread: true }

steps:
  - id: classify_intent
    uses: llm.classify
    input:
      text: string            # from trigger.body_plain
    output:
      intent: enum[refund, other]
      confidence: number      # advisory, never used to branch alone
    on_invalid_output: dead_letter

  - id: extract_order_id
    uses: llm.extract
    input:
      text: string
    output:
      order_id: string
    on_missing: ask_human

  - id: fetch_order
    uses: http.get
    input:
      order_id: string
    output:
      order: object           # guaranteed keys: id, status, total_cents, purchased_at
    on_error: retry(max=3, backoff=exponential)

  - id: policy_check
    uses: fn.evaluate
    input:
      order: object
      ruleset: string         # "refund-policy-v3"
    output:
      decision: enum[approve, reject, escalate]
      reason: string

  - id: draft_reply
    uses: llm.generate
    input:
      decision: enum
      reason: string
      order: object
    output:
      subject: string
      body: string
    on_invalid_output: dead_letter

  - id: route_approval
    uses: human.approve
    input:
      draft: object
    output:
      approved: boolean
      edited_body: string?

If your per-step prompts are growing past a paragraph, the contents of the context are worth designing deliberately. Context engineering covers what to include and what to leave out at each step.

Pass 3: Type the inputs and outputs

Untyped steps pass prose to each other and fail three steps later inside a prompt, where the error message is a paragraph. Give every step a declared input shape and output shape.

Two rules keep this honest:

  • Outputs are a closed set of fields. If a step outputs order: object, list the keys the next step may rely on. “Some JSON” is not a type.
  • A step either produces its declared output or fails. No “returns text that probably contains JSON.”

The payoff is at the boundary. A missing order ID stops at extract_order_id, not inside the reply generator where you have to read English to find out what went wrong.

from jsonschema import validate, ValidationError

ORDER = {
    "type": "object",
    "required": ["id", "status", "total_cents", "purchased_at"],
    "properties": {
        "id": {"type": "string"},
        "status": {"enum": ["paid", "shipped", "refunded"]},
        "total_cents": {"type": "integer"},
        "purchased_at": {"type": "string", "format": "date-time"},
    },
    "additionalProperties": False,
}

def guard(step_id, payload, schema):
    try:
        validate(payload, schema)
        return payload, None
    except ValidationError as err:
        return None, {"step": step_id, "reason": err.message, "payload": payload}

Typed outputs are also what let you swap a model or a provider without rewriting the steps around it.

Pass 4: Validation and the failure path

Every step needs a defined destination when it fails, and failures should be grouped by class rather than by step, because different classes want different responses.

Failure classDetected byActionWhere it lands
Bad inputInput schemaStop before the step runsDead letter
Bad model outputOutput schemaRetry once with a stricter prompt, then stopDead letter
Business rejectionPolicy stepContinue: rejection is a valid outcomeApproval queue with reason
External error (5xx, timeout)HTTP statusRetry with backoff, cap attemptsDead letter after the cap
Missing factRequired field absentAsk a human, do not guessHuman queue

One detail matters more than it looks: a policy rejection is not an error. A workflow that treats “declined” as a failure will bury real decisions in the error queue, and you will stop reading that queue within a week.

A dead-letter record should contain the workflow name, step ID, the input that entered the step, the raw output that failed validation, and a timestamp. A stack trace alone is not replayable.

# Replay a saved sample with no outbound calls and per-step tracing.
workflow run refund-triage --input ./fixtures/refund-01.json --dry-run --trace

# Run every fixture and print each step's typed output in order.
for f in fixtures/*.json; do
  workflow run refund-triage --input "$f" --trace
done

Pass 5: Run it once by hand

Before you schedule anything, run three inputs manually:

  • A clean refund request.
  • A message with no order ID in it.
  • An order that deliberately fails the policy.

Watch each step’s output as it moves. Check that the trigger fires exactly once, that classification is logged with the text it saw, that the malformed input stops at the boundary instead of reaching the model, and that the drafted reply is something you would actually send.

Then schedule it, with a concurrency limit of one and an alert on the dead-letter queue. Concurrency of one means two refund emails arriving together do not race on the same order record, which is the kind of bug that only shows up in production.

If you want the wider map of where this fits, the automation framework guide covers the surrounding pieces, and the solopreneur guide covers what is worth automating first.

What to do next

  1. Pick one request you currently handle by hand and write it as a single sentence. Do not edit it for elegance yet.
  2. Extract the trigger into its own line and confirm you can replay it without running any model.
  3. Write the spec as YAML with six steps or fewer, and one declared input and output shape per step.
  4. Hand-run the failure path first, not the happy path. A malformed input is the fastest way to find untyped boundaries.
  5. If writing the spec is the part you keep skipping, AI Workflow Builder ($99 USD, one-time) turns a plain-English prompt into validated multi-agent workflow definitions you can inspect, version and run. Treat its output as a draft spec to review line by line, not a finished workflow.

Get AI Workflow Builder

AI Workflow Builder โ€” $99, one-time payment, instant download. See the full breakdown on the review page.

About the author
Published by slashman413 โ€” writing practical, evergreen guides on money, productivity, developer tooling and the web. More about this site โ†’

Frequently Asked Questions

How detailed should the spec be before I start building?

Detailed enough that a second person could run it by hand: trigger, ordered steps, and one declared input and output shape per step. You will revise it after the first manual run anyway, so do not polish it first.

What is the difference between a dead-letter queue and a failure alert?

The queue stores the payload, the step ID and the raw output that failed so you can replay or repair it. An alert only tells you something broke. You want both, because an alert without a stored payload means the work is gone.

Can I reuse the same spec across different automation tools?

The step names, schemas and failure paths transfer cleanly. The bindings do not, meaning which HTTP client, model or queue each step actually calls. Keep the spec tool-neutral and write bindings separately, so a tool swap touches only the bindings.

๐ŸŽ Recommended Tools

๐Ÿ“š Related Articles

๐Ÿ“ฌ Free Weekly AI Product Guides

New tools, templates and automation walkthroughs โ€” plus hands-on updates on the Slashman Tools catalogue. One email a week, zero fluff.

Free forever ยท No spam ยท Unsubscribe anytime ยท Sent instantly

Join Free