· 9 min read

Automating a letter people show at the border

Employees travelling between Canada and the US carry a letter for the border officer. An agent now writes most of them with no person involved. What made that safe is what happens when a check fails. The letter goes to a person, never to the bin.

Employees who travel between Canada and the United States for work carry a letter for the border officer. It says who they are, who employs them, why they are crossing and where they will stay. Until this spring, every one of those letters waited for someone on the HR team to check it and send it.

Now an agent writes most of them. By the end of September it had produced 116 letters, and in its first three months 85% went out with no person involved. For the letters team, a request that took about ten minutes now takes one or two, and those minutes go to the requests that actually need a person.

Column chart of travel letters per month, April to September 2026: 8 (last three days of April), 21, 12, 24, 20 and 31. letters 0 10 20 30 Apr 2026: 8 letters (last three days only) Apr last 3 days May 2026: 21 letters May Jun 2026: 12 letters Jun Jul 2026: 24 letters Jul Aug 2026: 20 letters Aug Sep 2026: 31 letters Sep 31
Letters per month since launch. April covers only its last three days.
Table
MonthLetters
Apr 2026 (from the 28th)8
May 202621
Jun 202612
Jul 202624
Aug 202620
Sep 202631

This is how it works, and why the decision that mattered most was what happens when a check fails.

Why a person was in the loop

The letter is not paperwork for its own sake. An officer reads it to judge whether a trip is the business visit it claims to be. The human review existed to stop what the original ticket called “sham letters”: a trip described as business, on company letterhead, when it isn’t. Our legal team’s advice was that removing the review entirely was an unacceptable risk.

The cost of the review was just as concrete. The same ticket described leaders waiting over a weekend, or before an urgent trip, for someone in HR to approve a letter.

The idea we didn’t build

The first proposal was a trusted tier: check the requester’s seniority and let senior people skip the review. It would have fixed the complaint in the ticket. It would not have made a single letter more trustworthy. Seniority says nothing about whether this particular trip is a business trip, and everyone below the line would have kept waiting.

So the trust moved from the person to the request. Instead of asking who is asking, the system asks whether this specific letter passes every check we could write down. If it does, it goes out. If anything is off, a person looks.

Talking and deciding are separate

A sub-agent of our HR assistant does the talking. It covers trips between Canada and the US in both directions; anything else still goes through a manual request. It explains what it needs, collects fourteen fields and checks addresses with the employee as it goes. The country they travel from comes from their HR profile and can’t be typed in. They provide the rest: destination, purpose, dates, where they’re staying, who they’re visiting, a contact there, whether their manager knows and whether their passport is valid.

When the fields are complete, a separate validator decides. It is mostly plain code, with two small model calls inside. It starts every request as approved and runs thirteen checks in order, and a check’s only power over the outcome is to add a reason for a person to look. Nothing removes a reason once it’s added. The model that talks to the employee never makes the decision.

The employee chats with a sub-agent that collects 14 fields. A validator runs 13 checks in order. If all pass, the letter is emailed to the employee with the letters team copied. If any check adds a reason, the letter goes to HR for review with the reasons. Every letter writes one audit row. Employee asks in chat Sub-agent collects 14 fields checks addresses with the employee Validator: 13 checks, in order starts every request as approved a check can only add a reason for a person to look 9 in code · 2 through Google Maps · 2 through a model all pass any reason Sent to the employee PDF by email, letters team copied Sent to HR for review the same PDF, plus the list of reasons one audit row per letter, approved or not
The conversation and the decision are different components. A failed check sends the letter to a person instead of the employee.

Code for facts, a map for places, a model for language

Check How it’s checked
Permanent full-time employee HR profile
Manager is aware of the trip The employee’s answer
Trip is seven days or fewer Code
Dates are not in the past Code
One business location Code
Hotel address resolves Google Maps
Business address resolves Google Maps
Hotel is in the destination country, and in the same state or province as the business Code, on the Maps results
Purpose matches an approved business purpose Language model, against an approved list
Passport is valid for the trip The employee’s answer
Fewer than two letters today Code, against the audit log
Contact looks like a real person Language model
No red-flag wording Code, against a short word list

Nine of the thirteen are plain code. Dates, durations and counts don’t need a model, and asking one would only make them less reliable. Two call Google Maps, to make sure the hotel and the business exist where the employee says they do. Two call a language model, because their input is language: whether something like “presenting our roadmap to a customer’s fleet team” fits an approved business purpose, and whether “Kal-El, Chief Superhero” is a real contact.

The address checks shaped the conversation more than anything else. Google Maps gives three kinds of answer, not two: an exact match, a near miss, or nothing. A near miss is usually a missing suffix or postal code, so the agent shows the employee what Maps found and asks. If they confirm, Google’s version replaces theirs and the check passes. If they don’t, it asks for the address again. Only an address that never resolves goes to a person.

Employee

I need a travel letter for a customer visit in Atlanta, November 10 to 13.

Agent

Happy to help. Your profile says you’re based in Canada, so this letter covers travel from Canada to the United States. Would you like to go step by step, or give me everything at once?

Employee

Step by step.

A few questions later

Agent

Google Maps found a similar address: ‘255 Courtland St NE, Atlanta, GA 30303, USA’. Is this the correct address?

Employee

Yes, that’s it.

Agent

Is your manager aware of this travel?

Employee

Yes, I told them last week.

Purpose, contact and passport, then a summary to confirm

Agent

Thank you for using the travel letter generator. Your travel letter has been generated and auto-approved! It has been sent directly to your email (with People Ops CC’d for their records).

A redrawn conversation with an invented trip, shortened. The agent’s lines follow its prompt; nothing here comes from a real request.

The system never says no

Every check that fails does the same thing. The letter is still generated, and it goes to the HR letters team with the list of reasons instead of to the employee, who is told to expect an answer within two business days. A letter that passes everything is emailed to the employee, with the letters team copied. Either way, the request writes one row to an audit log.

That rule is what makes the design easy to defend. Whatever a check gets wrong, the worst case is the process we had before: a person reviews the letter. For the trips it covers, there is no path where the system refuses someone.

What the reviews changed

Before launch the design went through an application-security threat model and a privacy and security review of the code. The threat model listed twelve threats. Three were fixed before merge, and the rest were documented with the reasoning for accepting them. Three changes are worth telling.

The daily cap failed open. Each employee gets two automatic letters a day, and a third goes to a person. The cap is counted from the audit log. A reviewer noticed that if the log couldn’t be read, the code logged a warning and carried on, so an outage would quietly remove the limit. Now an unreadable log routes the request to a person, the same as an exceeded cap.

The cap was set to 9,999. For testing. The threat model listed it as the one thing that had to change before merge. Reviews catch configuration, not just code.

Audit or minimization. The privacy review argued that the audit log shouldn’t keep the external contact’s email or the employee’s passport answer, which is a fair data-minimization point. We took it to our privacy team, and their answer went the other way: the letter is a legal document shown at a border, so the record has to hold everything that went into it. We kept the fields and wrote down why. The conversation also opens with a notice that says what gets logged.

What broke on the way

  • A syntax error had been sitting in a retrieval module since the previous September, in a code path nothing had exercised.
  • “Give me everything at once” mode skipped the address checks. The prompt now insists that setting an address and checking it happen in order, even when every other field is set in one turn. The validator also geocodes both addresses again, so skipping the step can’t skip the check.
  • Upgrading the agent framework with its evaluation extras made the staging agent time out on startup. I pinned an older version.
  • The production audit table was missing eleven columns that the test table had.
  • Three days after launch, letters for visits to our own offices didn’t name the right legal entity. The fix was to choose the entity from the office’s country.

What I’d do differently

Watch the fallbacks. Every check that depends on another service has a fallback for when that service is down. A fallback should be rare, and I’d alert the moment one stops being rare.

Build the evaluation set first. A fixed set of requests with known right answers, run on every change, catches regressions like the bulk-input bug before a person does.

Ask the model checks for a schema, not prose. Free text is the hardest thing to parse reliably, and these two answers decide where a letter goes.

If you’re building something like this

  1. Split the conversation from the decision. The model that talks to people shouldn’t be the one that approves.
  2. Write a check as code wherever the input is a fact. Use a model only where the input is language.
  3. Make every failure an escalation to a person, never a refusal.
  4. Log every request with its reasons, approved or not. It’s your audit trail and your test set.
  5. For each check, decide what happens when its dependency is down, before you ship.