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.

TypeScriptReact 19Cloudflare WorkersDurable ObjectsLlama 3.3 70BLLaVA 1.5HonoD1Workers AI

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:

  1. Your message travels over a persistent WebSocket connection to the Hono router running on a Cloudflare Worker.
  2. 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.
  3. The request is routed to your personal Chat Durable Object, a stateful per-user instance that holds your full conversation history.
  4. 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.
  5. 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.

Open Live App โ†—