- Step 17 of 17 in the agent-building course
- Step 9 of 10 in the Agent Engineer path
Build a Ticket Workflow with Signed Delivery and Approval

A customer's refund question arrives twice. Your workflow finds the billing policy and suggests sending the ticket to billing. Someone approves. The database changes, and then the caller loses track of whether it worked.
When the next request comes in, the workflow has to find the result it already recorded and hand that back, without changing the ticket a second time.
This last lesson of the course builds that workflow and runs it on your own machine. It ties together the earlier lessons on receiving events, looking up documents, and getting approval. The HTTP requests and the SQLite database are real.
Three parts are simulated. The model's answers are fixtures, meaning answers written in advance and saved in a file the code reads instead of calling a model. The signing key is public on purpose. The reviewer is a fixed, made-up identity. You can inspect every handoff without paying for API access or connecting a real customer system.
What you end up with is a tested wrapper around a simulated model response. It isn't a deployed autonomous agent, and it tells you nothing about how well a live model would do.
Set up the files
You need Python 3.10 or newer and a terminal. The example uses only Python's standard library, so there's nothing to install. If the earlier pieces are new to you, do the webhook, RAG and approval lessons first.
Download and unzip the ticket workflow capstone. Each file has one job:
| File | Role |
|---|---|
capstone.py | HTTP delivery, inbox, preparation and command-line workflow |
policies.json | Four routing documents, including foreign-owner and inactive distractors |
model-output-fixtures.json | An authored billing proposal with a citation; no model call |
approval_lesson.py | Exact approval core from the earlier lesson |
rag_lesson.py | Exact retrieval module from the RAG lesson |
verify_capstone.py | Twelve integrated scenarios and source hashes |
observed-results.json | Recorded run for comparison |
Run the full test suite:
python3 verify_capstone.py > my-results.json
Use python if that's what your machine calls it. The recorded run used Python 3.12.14 and reported scenarios: 12, passed: 12, and model_requests: 0.
Each scenario gets its own temporary database. Events arrive as real HTTP requests over loopback, which means the request goes out and comes straight back to your own computer. If any check fails, the script exits with an error instead of writing a passing report.
Compare my-results.json with observed-results.json. The scenario names, outcomes and source_sha256 values should match. Your python version field may differ.
The source_sha256 values are fingerprints of the code and fixture files. If yours don't match, you're testing different files than the recorded run did. Sort that out before you read anything into a different outcome.
Follow one event through the system
Here's the path one event takes:
Signed HTTP body
-> validated, durable inbox row
-> owner-scoped policy retrieval
-> authored proposal + citation validation
-> stored exact proposal and pending approval
-> explicit reviewer decision
-> version-checked ticket update + receipt + audit event
Every stage works on the same event in the same database. The verifier doesn't just run the earlier lesson scripts side by side. Where the earlier approval and retrieval functions fit, it calls them directly.
The event carries an ID, the type ticket.created, ticket T100, and the question “Where should this refund request go?” It can't choose the owner, the reviewer, the database path or which tool runs.
The application sets the owner to demo-a and only allows this one ticket. That limit is part of the exercise setup. It isn't a user login.
Prepare the proposal without applying it
Start a fresh example:
python3 capstone.py prepare --db demo.sqlite
python3 capstone.py inspect --db demo.sqlite
Prepare won't overwrite a database that already exists, so use a new filename for each fresh attempt.
It starts a small receiver on 127.0.0.1 (your own machine), sends the signed event, shuts the receiver down, and processes the accepted work. Then it stops at a pending approval. The ticket is still unassigned at version 1, and there are zero receipts.
The receiver answers HTTP 202 with state received.
Here's how the signature works. The sender takes the timestamp, a period, and the exact body bytes, and runs them through HMAC-SHA256 with a shared key. The receiver does the same math and compares the two results.
Before it even reads the JSON, the receiver checks that the timestamp is recent and compares the signatures in constant time. Constant time means the comparison takes just as long whether the first character differs or the last one does, so nobody can guess a signature by timing the answers. Python's HMAC documentation covers the comparison function. This is the course's own format, not a GitHub or Stripe adapter.
The inbox saves the raw body and its hash before it says "accepted." If the same event ID arrives again with the same body, the receiver returns HTTP 200 with duplicate: true. If the same ID shows up with different signed content, it returns 409 and keeps the original question.
Because the example key is public, anyone with this download can make a valid signature. The point is to show how the check works, not to protect a real endpoint. The receiver only listens on your own machine, and only while it's delivering.
Inspect the evidence and proposed action
Retrieval first throws out any document that isn't active or doesn't belong to demo-a or public. Then it ranks what's left by how many words it shares with the question. For the refund question, the one document that qualifies is:
{
"id": "billing-routing",
"version": 1,
"text": "Send refund requests to billing review."
}
The fixture suggests the label billing and cites that document's exact version and text. Validation checks five things:
- the output has exactly the expected fields and nothing else
- the label is an allowed one
- the citation is one of the documents retrieval returned
- the quote matches word for word
- the label agrees with the routing in the policy document
An inactive document, or one owned by someone else, can't justify the answer just because its ID shows up in the response.
This proves something narrow: the suggested label is backed by a document the workflow was allowed to see. It doesn't prove retrieval found every relevant policy, or that a language model understood the question.
If the output fixture is missing, the workflow abstains. If the citation isn't supported, the work item is rejected. Neither one creates an approval.
Before asking for approval, the worker saves the whole proposal, including the ticket version it saw, when the proposal was made, and when it expires. Look at prepared and trace in the inbox output to see those records. The proposal should be:
{
"owner": "demo-a",
"ticket_id": "T100",
"label": "billing",
"expected_version": 1
}
Approve, apply and retry in separate processes
Read the proposal, then run these commands within 120 seconds. That's the window this exercise gives you: capstone.py sets expires_at to now + 120. It's a choice for this local example, not a standard approval timeout.
python3 capstone.py decide --db demo.sqlite --choice approved
python3 capstone.py apply --db demo.sqlite
python3 capstone.py apply --db demo.sqlite
python3 capstone.py inspect --db demo.sqlite
The first apply returns completed, replayed: false, billing, and version 2. The second returns the stored result with replayed: true. Inspect still shows one receipt and version 2.
Every command starts a new Python process. So that answer has to come from SQLite, not from something the program was holding in memory.
The receipt stays readable after the approval expires, because reading it only fetches a past result. It doesn't allow another update. If an approved proposal's first apply arrives once it has expired, it's refused. If you took too long during the walkthrough, start a new database and run Prepare again.
To see a rejection, prepare a different database and use --choice rejected. The next apply refuses with approval_not_active:rejected, and the ticket stays unassigned. The command exits with an error. That's what you want to see, because it's the proof that the refusal worked.
The inbox state review_created only means the work was handed to an approval record. It isn't the final status of the action. To tell pending, rejected, expired, stale and completed apart, read the approval state, the receipt and the audit history.
Test the two places a crash would hurt
The first is between preparing the work and creating its approval. Those are saved in two separate steps.
The test suite throws an error right after the approval is saved but before the inbox records the handoff. A new worker process then reads the same saved proposal and expiry, finds the approval that already exists, and finishes the handoff. It doesn't quietly rebuild the proposal against a newer ticket version, and it doesn't create a second approval.
The second is inside the action itself. Applying an approved action updates the ticket, the receipt and the audit log in one SQLite transaction, so either all three change or none do.
The suite throws an error right after the ticket update. A new process sees the old label, version 1 and zero receipts. A later apply then succeeds exactly once. Python's transaction-control documentation explains the commit and rollback tools used here.
These tests throw errors on purpose and check the result from a fresh process. They don't kill the process mid-write, cut the power, or test several machines at once. Run one worker at a time.
If the action changed a remote CRM instead of a local database, you'd need more. You'd need a way to recognize work the CRM already did (idempotency) and a way to check what actually happened there (reconciliation). A local transaction can't undo a change on someone else's server.
Read every result, including refusals
| Scenario | Required observed outcome |
|---|---|
| Completed action, new process, retry after expiry | Same stored result; one receipt |
| Ticket version changes after approval | Stale refusal; original label remains |
| First apply after expiry | Expired refusal; zero receipts |
| Reviewer rejects | Apply refuses; zero receipts |
| Apply before approval | Apply refuses; zero receipts |
| No retrieved evidence and abstaining output | No approval created |
| Citation names another owner's document | Output rejected; no approval created |
| Event targets a different ticket | HTTP 400 before inbox creation |
| Signature covers different bytes | HTTP 401 before inbox creation |
| Same ID, different signed content | HTTP 409; original event still completes normally |
| Stop after approval creation | New worker resumes; one approval and one final receipt |
| Failure inside action transaction | Rollback visible in a new process; retry completes once |
The suite also sends each valid event twice before processing, and checks that the worker goes idle after it hands off or refuses an item.
The report includes SHA-256 fingerprints of the code and fixtures. They tell you exactly which files were tested. They don't certify the application as secure.
Make one change and see what it does
In a throwaway copy, change the fixture's citation ID from billing-routing to other-owner and leave the other fields alone. Run Prepare against a new database.
The delivery should still be accepted, but processing should return rejected with unsupported_label_or_citation. Inspect should show zero approval records and zero receipts. Put the fixture back afterward.
That's the gap between a valid delivery and valid work. A sender with the right signature can still deliver a request whose proposal should be refused.
What to keep, and where to go next
Keep a small packet someone else could review in one sitting: the code and fixture hashes, the twelve-case report, one approval you inspected by hand, the changed-fixture result, and a list of what you didn't test.
If you later swap in a real model, keep this output format and add tests for answer quality, measurements of cost and timeouts, and inputs designed to trick it. If you swap in an outside service, test its actions and its recovery on their own.
That's the end of the course: a workflow anyone can rerun, plus a clear record of where it stops. The Agent Engineer career guide shows how to turn it into a portfolio someone can judge. The course index takes you back to any part you want more practice on.



