Guide
Build an Expense-Approval Email Agent
Manual expense approval is slow. Finance teams process each report by hand, chase missing receipts, and email decisions back — a loop that takes days and costs real money in staff time. An expense-approval agent on a dedicated inbox reads each submission, extracts the amount and expense category with a model, applies your threshold rules in code, and replies with the decision or escalates to a human within seconds of the email arriving. The actual payment stays outside the agent — it routes and notifies, nothing more.
Written by Nick Barraclough Product Manager
What is an expense-approval agent?
An expense-approval agent is an autonomous program that reads inbound expense-submission emails, extracts the key fields (amount, category, submitter), checks those fields against your policy thresholds in code, and sends an approval reply or routes the item to a human reviewer. It runs on a dedicated agent account so it owns the inbox and acts on every message without a human session in the loop. Classification takes under 200 milliseconds per submission — fast enough that a reimbursement submitted on a Friday evening gets a reply before Monday morning.
The agent deliberately stops at routing and notification. The actual payment instruction and ledger entry happen in your finance system, triggered by the approval record the agent produces. Keeping money movement outside the agent's scope is the right boundary: the agent can't be prompted into initiating a transfer, because that action simply doesn't exist in its tool set.
Why run expense approval on an agent account?
An expense agent should own its inbox, not share a person's mailbox. On an agent account, expenses@yourapp.nylas.email is the agent's own address, every received submission and sent decision lives in one managed identity, and you shut down the whole pipeline by suspending one grant — a 1-second operation versus revoking access for a shared human account that may span 5 or more integrations. A person's mailbox is the wrong place for an autonomous reader: the agent sees all personal email, any prompt injection in a submission could interact with unrelated threads, and there's no clean audit trail.
Expense emails are a classic example of what Simon Willison calls the lethal trifecta: private financial data (reimbursement amounts, employee IDs) plus untrusted content (the submission email anyone can send) plus external communication (the approval reply going back out). All three legs are present by design. Because the agent account's outbound rules live on the workspace — not inside the model's context — a crafted expense submission can't prompt its way past a rule that says "replies go only to your own domain." Containment lives outside the agent's decision loop.
How do I set up the expense inbox?
Create the expense inbox identity with a single command. The nylas agent account create call provisions an inbox and a grant in under 2 seconds. Export the grant ID so every subsequent command in the loop targets the right account:
nylas agent account create expenses@yourapp.nylas.email
export NYLAS_GRANT_ID="$(nylas agent account get expenses@yourapp.nylas.email --quiet)"Point your expense-submission form, Slack workflow, or internal portal at this address. The agent doesn't care about the sending channel — it reads whatever lands in the inbox. If you already route submissions to a shared alias, forward that alias to the agent address rather than rerouting your form. See Getting Started with Agent Accounts for the full workspace and grant model.
How does the agent read and extract expense details?
The agent polls for new submissions with nylas email list --unread --json. Each message carries a sender, subject, and body snippet. Projecting only those fields with jq before calling the model keeps the context window under 500 tokens per submission, reducing extraction cost to less than $0.002 per item at current frontier model pricing:
nylas email list --unread --json \
| jq '.[] | {id, from: .from[0].email, subject, snippet}'Give the model a strict output contract: a JSON object with amount, currency, category, and description. Separate the extraction prompt from the routing logic — the model's job is to read and structure; your code's job is to decide. Mixing them gives the model influence over thresholds it shouldn't control.
How does the threshold routing work?
Threshold routing is the decision that splits auto-approvals from escalations. The logic lives entirely in code, not in the model prompt. Your policy might say: anything under $100 in the "meals" category is auto-approved; anything over $500, or in the "equipment" category above $200, requires a human. Code handles that deterministically in under 1 millisecond per item. The model can't be persuaded to override it, because the model never sees the threshold.
#!/usr/bin/env bash
set -euo pipefail
# Thresholds (USD) — set in code, not the prompt
AUTO_APPROVE_MAX=100
EQUIPMENT_MAX=200
ESCALATE_MIN=500
route_expense() {
local amount="$1" category="$2"
# bc returns 1 for true, 0 for false
if (( $(echo "$amount >= $ESCALATE_MIN" | bc -l) )); then
echo "escalate"
elif [[ "$category" == "equipment" ]] && (( $(echo "$amount > $EQUIPMENT_MAX" | bc -l) )); then
echo "escalate"
elif (( $(echo "$amount <= $AUTO_APPROVE_MAX" | bc -l) )); then
echo "approve"
else
echo "escalate"
fi
}Keep the thresholds as named variables at the top of the script so a finance team member can read and adjust them without touching the model integration. Every routing decision should be logged to stdout so your audit trail captures amount, category, and outcome for each message. See the agent audit guide for structured logging patterns that feed a dashboard.
How does the agent send the approval or escalation?
For auto-approvals, the agent replies immediately with nylas email send. For escalations, it creates a draft addressed to the finance reviewer with nylas email drafts create so a human reviews the routing before the reply goes out. This two-path pattern means the agent handles auto-approvals in under 2 seconds while giving humans control over anything that needs judgment — typically 10-30% of submissions, depending on your threshold settings:
# Auto-approval path: reply immediately
nylas email send \
--to "$submitter_email" \
--subject "Expense approved: $description ($amount $currency)" \
--body "Your expense of $amount $currency for $description is approved. Reimbursement will process in the next payment run."
# Escalation path: create a draft for the finance reviewer
nylas email drafts create \
--to "finance-approvals@yourcompany.com" \
--subject "Expense needs review: $description ($amount $currency) from $submitter_email" \
--body "Amount: $amount $currency
Category: $category
Submitted by: $submitter_email
Description: $description
This submission exceeded the auto-approval threshold. Please review and approve or reject."The draft for an escalation sits in the agent's outbox until a human sends it, which matches the human-in-the-loop pattern. If your finance reviewer prefers a notification rather than a draft, replace the drafts create call with email send and include a link to your internal approval interface.
How do I wire the full approval loop?
The complete loop is a script a cron job runs every minute. It reads the first unread submission, calls the model to extract fields, routes based on the threshold function, sends or drafts the reply, and marks the message read so the next run skips it. End-to-end latency for an auto-approval — from cron trigger to sent reply — is typically under 5 seconds, dominated by the model extraction call. The whole loop runs under set -euo pipefail so any extraction or send failure surfaces immediately rather than silently dropping a submission:
#!/usr/bin/env bash
set -euo pipefail
# Thresholds
AUTO_APPROVE_MAX=100
EQUIPMENT_MAX=200
ESCALATE_MIN=500
route_expense() {
local amount="$1" category="$2"
if (( $(echo "$amount >= $ESCALATE_MIN" | bc -l) )); then echo "escalate"
elif [[ "$category" == "equipment" ]] && (( $(echo "$amount > $EQUIPMENT_MAX" | bc -l) )); then echo "escalate"
elif (( $(echo "$amount <= $AUTO_APPROVE_MAX" | bc -l) )); then echo "approve"
else echo "escalate"; fi
}
msg=$(nylas email list --unread --json | jq -r '.[0] // empty')
[ -z "$msg" ] && exit 0
id=$(echo "$msg" | jq -re '.id')
from=$(echo "$msg" | jq -re '.from[0].email')
snippet=$(echo "$msg" | jq -r '.snippet')
subject=$(echo "$msg" | jq -r '.subject')
# extract_expense is your LLM call — returns JSON: {amount, currency, category, description}
fields=$(extract_expense "$snippet" "$subject")
amount=$(echo "$fields" | jq -r '.amount // "0"')
currency=$(echo "$fields" | jq -r '.currency // "USD"')
category=$(echo "$fields" | jq -r '.category // "other"')
description=$(echo "$fields" | jq -r '.description // "expense"')
decision=$(route_expense "$amount" "$category")
case "$decision" in
approve)
nylas email send \
--to "$from" \
--subject "Expense approved: $description ($amount $currency)" \
--body "Your expense of $amount $currency for $description is approved."
echo "$(date -u +%FT%TZ) APPROVED amount=$amount category=$category from=$from"
;;
escalate)
nylas email drafts create \
--to "finance-approvals@yourcompany.com" \
--subject "Expense needs review: $description ($amount $currency) from $from" \
--body "Amount: $amount $currency
Category: $category
Submitted by: $from
Description: $description"
echo "$(date -u +%FT%TZ) ESCALATED amount=$amount category=$category from=$from"
;;
*)
echo "$(date -u +%FT%TZ) UNKNOWN decision=$decision from=$from" >&2
;;
esac
nylas email mark read "$id"Each branch logs a structured line to stdout — timestamp, decision, amount, category, and submitter. Pipe that output to a file or a log aggregator and you have a per-item audit trail with no extra instrumentation. The nylas email mark read call at the end is the deduplication fence: the next cron run won't see the same submission again, even if the script exits cleanly before sending.
How do I handle duplicate or malformed submissions?
Two failure modes hit every expense agent in production: the same submission arrives twice (because the submitter hit send again or your forwarding rule duplicated the message), and a submission arrives that the model can't parse cleanly (a forwarded thread with 8 quoted replies instead of a clean form fill). Both cases need explicit handling in the loop, not a silent skip.
For duplicates, track processed message IDs in a lightweight local store — a plain text file or a SQLite table works for teams processing under 500 submissions a day. Before calling the model, check whether the sender-plus-subject hash already appears in the store. If it does, mark the message read and exit cleanly without sending a second approval. For malformed submissions, check that the model returned non-empty amount and category fields before routing. A zero-amount or empty-category result means extraction failed, and the submission should be escalated to the finance team rather than auto-approved or silently dropped. Log the raw snippet alongside the escalation so the reviewer can see exactly what the model received:
# Check for duplicate before processing (append-only ID store)
PROCESSED_IDS="/var/run/expense-agent/processed.txt"
touch "$PROCESSED_IDS"
if grep -qF "$id" "$PROCESSED_IDS"; then
echo "$(date -u +%FT%TZ) DUPLICATE id=$id — skipping"
nylas email mark read "$id"
exit 0
fi
# Validate extraction — escalate if fields are missing
if [[ -z "$amount" || "$amount" == "0" || -z "$category" ]]; then
nylas email drafts create \
--to "finance-approvals@yourcompany.com" \
--subject "Expense parse failed: could not extract fields from $from" \
--body "Could not extract amount or category from submission.
Submitter: $from
Subject: $subject
Snippet: $snippet
Please process manually."
echo "$(date -u +%FT%TZ) PARSE_FAILED from=$from"
echo "$id" >> "$PROCESSED_IDS"
nylas email mark read "$id"
exit 0
fi
# Record the ID so future runs skip it
echo "$id" >> "$PROCESSED_IDS"The PROCESSED_IDS file grows at roughly 25 bytes per submission. At 100 submissions per day it reaches 1 MB in under a year — small enough to rotate monthly without losing dedup coverage for any realistic re-submission window. For higher volumes, a keyed store (Redis, SQLite) with a 30-day TTL on each entry is cleaner than an append-only file.
How do I contain the agent's outbound reach?
Outbound containment for an expense agent means two rules: approvals go only to employees, and escalation drafts go only to the finance team. Because the rules live on the workspace, not in the agent's prompt, a malicious submission email can't prompt its way past them. Create a blocking rule for any recipient domain that isn't yours, then add a send-rate policy so a loop bug can't flood the inbox with 1,000 approval replies:
# Block replies to domains outside your org
nylas agent rule create \
--name "Block expense-agent exfil" \
--trigger outbound \
--condition recipient.domain,is,exfil.example \
--action block
# Broader block: only allow outbound to your own domain
nylas agent rule create \
--name "Restrict expense-agent outbound to org" \
--trigger outbound \
--condition recipient.domain,is,untrusted-domain.example \
--action block
# Create a send-rate policy to cap daily volume
nylas agent policy create \
--name "Expense agent send cap" \
--max-sends-per-day 200The 200-sends-per-day cap is a reasonable ceiling for a mid-size team: a company processing 40 expense reports a day with an average of 3 emails per report (submission, approval, and one follow-up) reaches 120 — well under the limit. Adjust the cap to match your actual volume. Pair these rules with the patterns in Agent Rules and Policies and Stop Your AI Agent From Going Rogue for the full containment model.
How do I verify the agent is working?
Send a test submission to the expense inbox and confirm the full loop runs end to end. The verification below uses nylas email list --json to confirm the message was received and marked read, and checks the sent folder for the approval reply. A 15-second round-trip from send to approval reply is a reasonable baseline for a model that's under 500ms on extraction:
# Send a test submission (replace with your own expense address)
nylas email send \
--to expenses@yourapp.nylas.email \
--subject "Expense claim: team lunch $45" \
--body "Claiming $45 USD for team lunch (meals category). Receipt attached."
# After the cron runs, confirm the submission was marked read
nylas email list --json | jq '.[] | select(.subject | test("team lunch")) | {id, unread}'
# Check the approval reply was sent
nylas email list --json | jq '.[] | select(.subject | test("approved")) | {subject, to}'Tested on Nylas CLI 3.1.16 with the Nylas managed provider. Provider-side behavior for other backends is described from documented behavior — verify end-to-end locally before deploying to a different email backend.
Next steps
- Getting Started with Agent Accounts — workspace model, grant lifecycle, and the inbox provisioning this agent depends on
- Build a Lead-Capture & Qualification Agent — same read-extract-route-reply pattern applied to inbound sales leads
- Build a Human-in-the-Loop Email Agent — draft-based escalation pattern for high-value or ambiguous items
- Agent Rules and Policies — every trigger, condition, and action available for outbound containment
- Stop Your AI Agent From Going Rogue — full lethal-trifecta containment model for agents that read untrusted email
- Full command reference — every
nylas email,nylas agent, andnylas contactsflag documented - Nylas v3 API documentation — the API surface behind these commands