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.
Table
| Month | Letters |
|---|---|
| Apr 2026 (from the 28th) | 8 |
| May 2026 | 21 |
| Jun 2026 | 12 |
| Jul 2026 | 24 |
| Aug 2026 | 20 |
| Sep 2026 | 31 |
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.
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).
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
- Split the conversation from the decision. The model that talks to people shouldn’t be the one that approves.
- Write a check as code wherever the input is a fact. Use a model only where the input is language.
- Make every failure an escalation to a person, never a refusal.
- Log every request with its reasons, approved or not. It’s your audit trail and your test set.
- For each check, decide what happens when its dependency is down, before you ship.
