AI Engineering ยท Solo Project
ApprovalFlow AI
A production-grade conversational AI agent that handles enterprise HR approval workflows โ PTO requests, expense reimbursements, and policy questions โ through natural language, voice, and receipt images.
The Problem
Enterprise approval workflows are a textbook example of high-frequency, low-complexity work that somehow still consumes enormous amounts of human time. An employee needs a few days off. They open an email, CC their manager, wait two days for a reply, and then someone manually checks the policy document to confirm the balance. An expense receipt gets emailed to HR, where it sits in a queue until someone opens a spreadsheet.
The decisions involved are almost always the same: check the balance, verify the policy, confirm no conflicts, then approve or escalate. The bottleneck isn't judgment โ it's process.
The goal with ApprovalFlow AI was to eliminate that bottleneck entirely, while building something that demonstrates real production AI engineering patterns: not a chatbot that summarizes text, but an agent that takes consequential action in a production system.
The Solution
ApprovalFlow AI is a conversational agent that handles two core enterprise workflows entirely through natural language:
- โ PTO requests. Tell the agent how many days you need and when. It checks your leave balance, calculates business days (excluding company holidays), flags any blackout periods, and applies role-based approval limits. Routine requests get approved instantly. Anything that exceeds your limit or conflicts with a restricted period gets escalated to your manager with full context attached.
- โ Expense reimbursements. Snap a photo of your receipt. The agent uses a vision model to extract the merchant name, amount, date, and category. It checks those values against company policy thresholds and either approves the expense automatically or routes it for review.
The agent also answers policy questions in natural language, sourcing answers from the employee handbook rather than guessing. Every action it takes โ approval, denial, escalation โ is logged with a tamper-evident audit trail that explicitly records whether the actor was a human or the AI agent.
Architecture
The entire stack runs on Cloudflare's edge network. There are no centralized servers, no external LLM API keys, and no persistent background processes. Every component โ compute, database, AI inference, and session state โ lives inside Cloudflare's infrastructure.
flowchart TB
Browser["Browser (React 19 + Vite 7)"]
Hono["Hono Router + Session Middleware"]
DO["Chat Durable Object (per-user)"]
ReAct["ReAct Agent Loop (15 iterations max)"]
LLM["Workers AI โ Llama 3.3 70B"]
Tools["Tool Registry โ 15 tools"]
D1["Cloudflare D1 (relational DB)"]
WAI["Workers AI โ OCR and Whisper"]
Browser -->|"WebSocket"| Hono
Browser -->|"HTTP"| Hono
Hono -->|"validate session, inject X-User-Id"| DO
DO --> ReAct
ReAct -->|"generateText"| LLM
LLM -->|"TOOL_CALL response"| ReAct
ReAct -->|"execute"| Tools
Tools -->|"SQL queries"| D1
Tools -->|"vision and audio"| WAI
When you send a message, here's exactly what happens:
- Your message travels over a persistent WebSocket connection to the Hono router running on a Cloudflare Worker.
- Hono validates your session cookie against the D1 database and injects your authenticated user ID as an internal request header โ never derived from your message.
- The request is routed to your personal Chat Durable Object, a stateful per-user instance that holds your full conversation history.
- The Durable Object starts the ReAct agent loop, which alternates between calling the language model and executing tools until it arrives at a final answer.
- Each tool execution streams back to your UI in real time, so you can watch the agent's reasoning unfold step by step.
Walkthrough: A PTO Request
Say you type: "I need time off from December 1st to December 5th." The agent runs a strict six-step tool sequence โ enforced in the system prompt, not improvised at runtime:
flowchart LR
Input(["I need PTO Dec 1-5"])
T1["get_current_user"]
T2["get_pto_balance"]
T3["calculate_business_days"]
T4["check_blackout_periods"]
T5["validate_pto_policy"]
T6["submit_pto_request"]
Auto(["Auto-Approved"])
Escalate(["Sent to Manager"])
Input --> T1 --> T2 --> T3 --> T4 --> T5 --> T6
T6 -->|"Within role limit, no conflicts"| Auto
T6 -->|"Exceeds limit or blackout date"| Escalate
The agent cannot skip balance validation to jump straight to submission. It cannot fabricate a result; every outcome is derived from a real database query. Role-based approval limits apply automatically based on the authenticated user's level:
| Employee Level | PTO Auto-Approval | Expense Auto-Approval | Receipt Required Above |
|---|---|---|---|
| Junior | โค 3 business days | โค $100 | $75 |
| Senior | โค 10 business days | โค $500 | $75 |
If your requested dates overlap with the Q4 blackout period (December 24โ31), the agent catches it at step four and escalates without ever attempting to submit. No manual policy lookup. No waiting.
Walkthrough: Expense Reimbursement
Expense submissions are triggered by natural language. When the agent detects intent to file an expense, it calls a special tool called show_expense_dialog. This sends a marker to the React frontend, which interprets it as a signal to open a multi-step submission modal โ a clean example of an agent driving UI state.
flowchart TD
Msg(["I have a receipt to submit"])
Dialog["show_expense_dialog โ UI modal opens"]
Upload["User uploads receipt (JPEG, PNG, PDF)"]
OCR["Workers AI Vision Model โ extracts merchant, amount, date"]
Form["Pre-populated form โ user reviews and confirms"]
Policy["validate_expense_policy"]
AutoApprove(["Auto-Approved"])
Escalate(["Sent to Manager"])
Log["audit_log written โ actor_type: ai_agent"]
Msg --> Dialog --> Upload --> OCR --> Form --> Policy
Policy -->|"Under threshold, receipt present"| AutoApprove
Policy -->|"Over threshold or no receipt"| Escalate
AutoApprove --> Log
Escalate --> Log
The receipt image is sent to a Workers AI vision model, which returns structured data: merchant name, total amount, date of purchase, and expense category. That extracted data pre-populates the form, eliminating manual entry and the transcription errors that come with it.
Every approved or escalated expense writes an audit log record that captures what changed, who made the change, and whether the actor was a human or the AI agent. This distinction โ actor_type: 'ai_agent' โ is a compliance pattern increasingly required in regulated environments.
Engineering Decisions That Matter
Why a Manual ReAct Loop?
Most agentic frameworks let you pass a tools array to the model and let the SDK handle tool-calling transparently. That's the obvious path โ but it didn't work here.
The workers-ai-provider package, which bridges the Vercel AI SDK to Cloudflare's Workers AI binding, doesn't reliably support structured tool schemas. When tools are passed through the SDK, the Llama model either ignores them or produces malformed output. This was discovered during model evaluation, where multiple candidates were tested across different function-calling approaches before settling on Llama 3.3 70B.
The solution was to implement the ReAct loop manually, using a plain-text tool-calling protocol embedded in the LLM's output:
TOOL_CALL: get_pto_balance
PARAMETERS:
--- Two regular expressions extract the tool name and parameters from each LLM response. If both match, the tool executes and its result is injected back into the conversation as an observation. If neither matches, the response is treated as the final answer. The loop runs up to 15 iterations โ a hard cap that prevents runaway inference costs.
flowchart TD
Msg["User Message"]
LLM["LLM generates response (Llama 3.3 70B)"]
Parse["Parse output for TOOL_CALL pattern"]
Found{"Tool call found?"}
Execute["Execute tool from registry"]
Inject["Inject result as Observation into context"]
Cap{"Iteration under 15?"}
Answer["Return final answer to user"]
Stop["Return with iteration cap notice"]
Msg --> LLM --> Parse --> Found
Found -->|"Yes"| Execute --> Inject --> Cap
Cap -->|"Yes, keep going"| LLM
Cap -->|"No"| Stop
Found -->|"No"| Answer
Because language models occasionally emit slightly malformed JSON in their parameter blocks, the agent applies four targeted string fixes before parsing โ handling common failure modes like trailing commas, missing closing quotes, and empty values. If parsing still fails after recovery, the agent injects a TOOL_ERROR observation and the model self-corrects in the next iteration rather than crashing.
Security: The User ID Never Comes From the Agent
A critical property of any multi-user AI system is that the authenticated user identity must flow from the server โ never from the agent's input or the user's message. If the agent could accept an employee_id parameter from the user's prompt, a malicious user could ask it to retrieve someone else's leave balance.
In ApprovalFlow AI, the userId flows through a strict server-side chain: session cookie โ Hono middleware validates it against D1 โ injected as an X-User-Id header โ stored in the Durable Object โ passed to every tool via a typed ToolContext. Tools that operate on the current user explicitly ignore any employee_id the agent might attempt to pass.
This isn't just architecture โ it's also tested. One of the golden queries in the evaluation suite asks the agent to retrieve another user's PTO balance. The expected result is a refusal and zero data leakage.
Streaming Without the Provider Bug
The workers-ai-provider streaming implementation had a double-emit bug: each token appeared twice in the output. Rather than work around the bug, the solution was to use atomic generateText calls per ReAct iteration (non-streaming) while preserving the real-time UX by emitting discrete tool execution events via an onToolUpdate callback.
The React UI renders each tool invocation as a collapsible card showing the tool name, arguments, and result as the agent works. Users see the agent checking balances, querying the calendar, and submitting records in real time โ which builds trust and makes the system debuggable when something goes wrong.
Evaluation: Testing What Actually Matters
Most AI demos test happy paths against mocked responses. ApprovalFlow AI includes a structured evaluation suite of 10 golden queries that run against real Workers AI inference โ actual Llama 3.3 70B, not a stub. If the model changes or the system prompt is modified, the evals catch regressions before they reach the live app.
Each query specifies four graders:
- โ tools_required โ which tools must appear in the execution trace
- โ tools_forbidden โ which tools must not be called (no database queries on a simple greeting)
- โ response_contains_any โ key phrases the answer must include
- โ response_contains_none โ values that must never be revealed (another user's balance, internal IDs)
The suite covers greetings, ambiguous requests that should trigger clarification, the full PTO workflow, blackout period detection, expense happy paths, over-threshold expense handling, and cross-user data isolation. That last category is the most important โ it verifies that the security boundaries hold under real model inference, not just in code review.
Tech Stack
Edge Runtime
Cloudflare Workers ยท Durable Objects ยท D1 (SQLite) ยท Workers AI ยท Wrangler
AI & Agent
Llama 3.3 70B Instruct (FP8 Fast) ยท LLaVA 1.5 (vision/OCR) ยท Whisper (audio) ยท Cloudflare Agents SDK ยท Vercel AI SDK ยท Manual ReAct loop
Frontend
React 19 ยท Vite 7 ยท Tailwind CSS v4 ยท Radix UI ยท react-markdown
Backend
Hono v4 ยท TypeScript 5.9 (strict) ยท PBKDF2-SHA256 auth ยท HTTP-only session cookies
Quality
Vitest 3 ยท @cloudflare/vitest-pool-workers ยท Biome 2 ยท Prettier 3 ยท GitHub Actions CI
Demo
Three pre-seeded demo users let you explore the full policy matrix in the live app:
- โ
ramya_juniorโ Engineering, 11.5 days PTO, $100 expense auto-approval limit - โ
ramya_seniorโ Engineering, 18 days PTO, $500 expense auto-approval limit - โ
ramya_managerโ People Ops, 38 days PTO, $500 expense limit
Try requesting PTO over the Q4 blackout period, submitting an expense that exceeds your role's threshold, or uploading a receipt image to see the OCR pipeline extract the details.