Skip to content
Zarif Automates

Plan and Execute: Build a Bounded Ticket Workflow

ZarifZarif
|Published |Updated

A ticket workflow needs to read a ticket, load a labeling policy, propose a label and prepare a review. If the ticket lookup fails, the proposal has no record to work from. An executor should make that dependency explicit rather than ask a model to improvise around the missing data.

In this lesson you will run those four steps, inspect their inputs and outputs, then break the lookup, reduce the work budget and cancel the run. The artifact uses a fixed JSON plan and deterministic Python handlers. It tests execution behavior. It does not call or evaluate an LLM planner.

Start with a workflow you can inspect

You need Python 3.10 or newer and the ability to run a terminal command. The tool-use lesson provides the background for argument validation and bounded dispatch. This example uses only the standard library and synthetic ticket data. No credentials, network access or external writes are required.

Download and extract the bounded workflow example. Keep its files together:

workflow.py
plan.json
expected-results.json
README.md

Run it from that folder:

python3 workflow.py > observed.json

On a system where Python is named python, use that command instead. Run without -O, because the example's checks use assertions. A failed check exits with an error. A successful run prints four traces and six rejected-input results: ten scenarios in total.

Anthropic's December 2024 agent-patterns article distinguishes predefined workflows from systems in which a model selects its own next actions. That distinction is useful here. A known sequence is enough for this ticket task. An LLM would add no necessary planning capability to the example.

Read the plan before running the handlers

The complete plan is small enough to review directly:

{
  "revision": 1,
  "steps": [
    {"id": "ticket", "action": "read_ticket", "depends_on": []},
    {"id": "policy", "action": "read_policy", "depends_on": []},
    {"id": "propose", "action": "propose", "depends_on": ["ticket", "policy"]},
    {"id": "review", "action": "prepare_review", "depends_on": ["propose"]}
  ]
}

validate() checks the whole plan before any handler runs. It accepts one to eight steps, rejects duplicate identifiers and unknown actions, and requires dependencies to name earlier steps. That last rule rejects both forward references and cycles. It also checks each action's input contract: propose must consume a ticket result followed by a policy result, not two arbitrary successful steps.

This is a deliberately restricted plan format. It does not execute Python supplied in JSON, load a named module or interpret a shell command. Adding a new capability means adding a handler and a dependency contract in code. The plan itself cannot increase the execution budget.

The executor copies the validated plan and records its revision and SHA-256 digest. Those identify the plan used for the trace. The digest does not authorize the work or prove that a future version of a handler behaves the same way.

Follow the successful data path

The ticket handler returns T100, version 1, with the text “I was charged twice.” The policy handler returns the allowed labels and policy version policy-1. The proposal handler applies a fixed keyword rule and carries the observed versions forward:

{
  "ticket_id": "T100",
  "ticket_version": 1,
  "label": "billing",
  "policy_version": "policy-1"
}

The final handler wraps that proposal in an awaiting_human result. Nothing updates the ticket. In observed.json, inspect runs.success: it has four dispatches, stop_reason: "finished", four successful steps and applied_changes: 0. Here, finished means the workflow prepared its review output. It does not mean a person approved or applied it.

Every trace row contains the step ID, action, state and either its output or failure reason. A downstream handler receives copies of its declared dependencies' outputs. It does not receive an unrestricted store of every result from the run.

Break the lookup and inspect what still runs

The fixture lookup_unavailable makes read_ticket raise a LookupError. The executor records the failure, then runs the independent policy read. It skips both descendants of the failed ticket lookup:

StepLookup-failure resultReason
ticketFailedticket_unavailable
policySucceededDoes not depend on the ticket
proposeSkippedA required dependency failed
reviewSkippedIts proposal dependency did not succeed

The failed lookup still consumes one dispatch. This run uses two dispatches total and ends with dependency_failure. That policy permits independent reads to finish. If your task requires stopping all work after any failure, change the policy explicitly and update the expected trace. Do not assume those two behaviors are equivalent.

The artifact also rejects six invalid inputs: an unknown action, a forward dependency, a missing policy dependency, a duplicate ID, an oversized plan and a boolean supplied as the numeric budget. These are rejected before execution rather than reported as model mistakes.

Bound work and cancellation separately

With max_dispatches=2, the two reads succeed, and the proposal and review are skipped with budget_exhausted. With cancel_before="propose", the same two reads finish, but the stopping reason is cancelled. Keeping those reasons distinct helps someone decide whether to allocate more work or honor a cancellation.

These checks run between synchronous handlers. They cannot interrupt a handler that hangs, bound wall-clock time or recover a crashed process. A remote adapter needs its own request timeout and a persistence strategy. There is no automatic retry in this example.

Make one change and predict the trace

In a separate Python session in the extracted folder, run a three-dispatch version:

import json
from pathlib import Path
from workflow import run

plan = json.loads(Path("plan.json").read_text())
result = run(plan, max_dispatches=3)
print([(row["step"], row["state"]) for row in result["trace"]])
print(result["stop_reason"])

The expected states are successful ticket, policy and propose, followed by skipped review. The reason remains budget_exhausted. A proposal exists, but the workflow has not prepared its final review output.

Next, set the last step's depends_on to ["policy"] and call run() again. It should raise ValueError: action dependency contract before executing any step. A dependency name that exists is not enough. It must provide the kind of input the action requires.

If you later add model-generated plans, pass them through the same validator and keep the remaining budget outside model control. A new plan revision should not reset work already spent. This example provides no replanning implementation, so test that accounting separately before introducing it.

Continue with human approval, where the executor checks that a reviewed proposal still matches the target record before applying it. The course index shows the full sequence.

Zarif

Zarif

Zarif builds AI agents and automation workflows and writes about what holds up in production: the sources worth following, the roles the AI era is creating, and agent workflows you can inspect end to end.