# Plan and Execute: Build a Bounded Ticket Workflow

> Run a four-step ticket workflow with validated dependencies, a dispatch budget, cancellation and traces of failed prerequisites.

- Source: https://www.zarifautomates.com/blog/how-to-build-ai-agent-that-plans-and-executes-tasks
- Published: 2026-09-17
- Updated: 2026-09-19
- Pillar: Agents & AI Engineering
- Tags: agent-course, ai-engineering, practical-guide
- Author: Zarif

---

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](/blog/how-to-build-an-ai-agent-with-tool-use-and-function-calling) 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](/downloads/agent-course/bounded-workflow-v1.zip). Keep its files together:

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

Run it from that folder:

```bash
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](https://www.anthropic.com/engineering/building-effective-agents) 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:

```json
{
  "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:

```json
{
  "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:

| 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 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:

```python
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](/blog/how-to-build-ai-agent-human-in-loop-approval), where the executor checks that a reviewed proposal still matches the target record before applying it. The [course index](/blog/pillar/agents-and-ai-engineering#agent-course-heading) shows the full sequence.

## Related Guides

- [Build a Ticket Workflow: Signed Delivery, Evidence and Human Approval](/blog/ticket-agent-workflow-capstone)
- [Build an MCP Server and Client for a Ticket-Label Tool](/blog/how-to-build-an-ai-agent-using-mcp-model-context-protocol)
- [Use Cursor to Build a Ticket Approval Interface You Can Test](/blog/how-to-use-cursor-ai-to-build-web-applications)

## Continue the course

Lesson 8 of 17.

Previous lesson: [Build Persistent Agent Memory with Updates, Expiry and Deletion](https://www.zarifautomates.com/blog/how-to-build-ai-agent-with-persistent-memory.md).

Next lesson: [Human Approval for Agents: Store and Check the Exact Decision](https://www.zarifautomates.com/blog/how-to-build-ai-agent-human-in-loop-approval.md).

[Browse available lessons](https://www.zarifautomates.com/blog/pillar/agents-and-ai-engineering#agent-course-heading).
