- Step 8 of 17 in the agent-building course
Plan and Execute: Build a Bounded Ticket Workflow

A support-ticket workflow has four jobs. Read the ticket, load the labeling rules, suggest a label, and get that suggestion ready for a person to review. If reading the ticket fails, there's nothing to suggest a label for.
The code running the steps should know that and skip ahead cleanly. It shouldn't hand a model a missing ticket and hope it makes something up sensibly.
In this lesson you'll run those four steps and look at what goes in and out of each one. Then you'll break the ticket lookup, shrink the work budget and cancel a run. The example uses a fixed JSON plan and plain Python functions that give the same result every time. It tests how the steps run. It doesn't call or grade an LLM planner.
Start with a workflow you can inspect
You need Python 3.10 or newer and a terminal. The tool-use lesson covers the background: checking a tool's arguments and capping how many calls run. This example uses only Python's standard library and made-up ticket data. You don't need keys, internet access, or anything outside the folder.
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
If your system names Python python, use that instead. Don't add -O, because the example's checks use assertions and -O switches them off. If a check fails, the script exits with an error. A successful run prints four traces, which are step-by-step records of a run, and six rejected inputs. That's ten scenarios in total.
Anthropic's December 2024 agent-patterns article separates two kinds of system. In a workflow, the steps are decided in advance. In an agent, the model picks its own next action. This ticket task has a known order, so a fixed workflow is enough. An LLM wouldn't add any planning the example needs.
Read the plan before running the handlers
Each step in the plan is carried out by a handler, a small Python function that does that one job. The whole plan is short enough to read:
{
"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 allows one to eight steps. It rejects repeated IDs and actions it doesn't know. Every dependency has to name a step that comes earlier in the list, which rules out both pointing ahead and going in circles.
It also checks that each action gets the inputs it expects. propose must get a ticket result and then a policy result, not just any two steps that succeeded.
The plan format is limited on purpose. It can't run Python code written into the JSON, load a module by name, or run a shell command. To add something new the workflow can do, you add a handler and its expected inputs in code. The plan itself can't raise the work budget.
The executor, the code that runs the steps, copies the checked plan and records its revision number and a SHA-256 fingerprint. Those tell you exactly which plan a trace came from. The fingerprint doesn't give permission to do anything, and it doesn't prove a later version of a handler will behave 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 passes along the versions it saw:
{
"ticket_id": "T100",
"ticket_version": 1,
"label": "billing",
"policy_version": "policy-1"
}
The last handler wraps that proposal in an awaiting_human result. Nothing updates the ticket.
In observed.json, look at runs.success. It has four dispatches (handler calls), stop_reason: "finished", four successful steps and applied_changes: 0. Here, finished means the workflow got its review ready. It doesn't mean a person approved it or that anything was changed.
Every trace row has the step ID, the action, the state, and either the output or the reason it failed. A handler only gets copies of the outputs from steps it lists as dependencies. It can't see every result from the run.
Break the lookup and inspect what still runs
The test setting lookup_unavailable makes read_ticket raise a LookupError. The executor records the failure and still runs the policy read, which doesn't need the ticket. It skips the two steps that depend on the failed lookup:
| Step | Lookup-failure result | Reason |
|---|---|---|
ticket | Failed | ticket_unavailable |
policy | Succeeded | Does not depend on the ticket |
propose | Skipped | A required dependency failed |
review | Skipped | Its proposal dependency did not succeed |
The failed lookup still counts as one dispatch. This run uses two dispatches in total and ends with dependency_failure.
That's a choice. This workflow lets steps that don't depend on the failure finish. If your task needs everything to stop the moment anything fails, change that rule on purpose and update the expected trace. The two behaviors aren't the same, so don't treat them as if they were.
The example also rejects six bad inputs: an unknown action, a dependency that points ahead, a missing policy dependency, a repeated ID, a plan that's too long, and a true/false value passed where the budget number should go. All six are rejected before anything runs, instead of being reported later 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 stop reason is cancelled.
Keeping those reasons separate tells the next person what to do. Out of budget means they might give the run more room. Canceled means someone asked it to stop, and that request should be respected.
These checks happen between handlers, which run one after another. They can't interrupt a handler that hangs, limit how long the whole run takes, or recover from a crash. A handler that calls a remote service needs its own request timeout and a way to save progress. This example doesn't retry anything automatically.
Make one change and predict the trace
In a separate Python session in the extracted folder, run a version capped at three dispatches:
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"])
You should see ticket, policy and propose succeed, and review skipped. The reason is still budget_exhausted. A proposal exists, but the workflow never got its review ready.
Next, set the last step's depends_on to ["policy"] and call run() again. It should raise ValueError: action dependency contract before running any step. Naming a step that exists isn't enough. That step has to provide the kind of input the action needs.
If you later let a model write the plans, pass them through the same validator, and keep the remaining budget out of the model's hands. A new version of the plan shouldn't reset the work already spent. This example doesn't include replanning, so test that bookkeeping on its own before you add it.
Up next is human approval, where the executor checks that a reviewed proposal still matches the ticket before applying it. The course index shows the full sequence.



