Skip to main content

The letter request flow

An employee asks for an employment, salary or visa letter. A manager can request one for a team member. It's the guide's worked example, because it carries every HR habit worth learning: an access check that has to gate the generate step, validation that belongs to a workflow rather than the model, and a system failure that must not look like a refusal.

Same skeleton for all three letter types — only the generate workflow changes.

HR-letter request
StartStart
Agentverify
Confirm the employee loaded at session start, and that letters are available to them.
eligible→ access-checknot_eligible→ End (business)not_identified→ Transfer to HR
Agentaccess-check
Decide whether they may act on another employee's record, or only their own.
@accessCheck
self→ collect-detailsmanager→ collect-team-member
Agentcollect-team-member
Get the team member code the manager is requesting for.
collected→ collect-detailsmax_retry→ Transfer to HR
Agentcollect-details
Collect a valid letter type and year.
@validateYear
collected→ generate-and-delivermax_retry→ Transfer to HR
Agentgenerate-and-deliver
Generate the letter and get it to them the way they want it.
@generateLetter@notify
delivered→ Endgeneration_failed→ Transfer to HR
The HR letter flow canvas.
1/2The letter flow on the canvas: verify, the self-vs-manager access check, then collect, generate, and deliver.

The Start trigger​

A flow is unreachable until its Start trigger is set. This one has to catch both the direct ask and the hand-off from the entry menu:

When the employee asks for an employment letter, salary letter, visa letter or
salary certificate — or when selectedService is letter_request.
Not for payslips, and not for questions about what a letter contains.

The "not for…" line matters more than it looks. Payslip requests phrase almost identically, and without the exclusion this flow steals them.

Node by node​

verify — is this person allowed a letter at all?​

verifyAgent node
GoalConfirm the employee record loaded at session start, and that letters are available to them.
Instructions
The employee record was loaded when the session started. Read {{employee}}.
- If it is empty, or its status says the lookup failed, do not assume they are
ineligible. Say you can't confirm their details right now and finish on
not_identified.
- If the record's entitlements include letters, finish on eligible.
- If they don't, tell them plainly that letters aren't available on their
employment type and finish on not_eligible.
Send no message on the turn you exit on eligible.

It reads, it doesn't fetch. @getEmployeeDetails ran once in the session-start hook, so this node has no identity tool attached — one fewer tool weighed on every turn, and one fewer thing to pick wrongly.

Three exits, not two. not_eligible is a business outcome and ends the conversation politely. not_identified is a system problem and goes to a person. Collapsing them is the single most common HR mistake: during an outage, every legitimate employee is told they aren't entitled.

access-check — may they act on someone else's record?​

access-checkAgent node
GoalDecide whether this person may act on another employee's record, or only their own.
Instructions
Call @accessCheck for the current user and read isManagerAllowed.
- If true, finish on manager. A later step will collect the team member's code.
- If false, finish on self. If they have already named another employee, first
tell them plainly you can only handle their own record.
- If the check fails or returns nothing, finish on self. Least privilege: never
widen access because a check was unavailable.
Send no message on the turn you exit.

This is why the journey is a flow and not a single agent. "Only issue letters for the employee's own record" written into instructions reads perfectly and holds most of the time. Here the wiring enforces it: a self employee is never routed to a node that can accept a colleague's code, so there is no path along which the mistake can happen.

Failure closes rather than opens. Notice the last rule — a failed check exits self, the narrower outcome. That's the opposite of the verify node's failure handling, and deliberately so: not knowing who someone is means stop, while not knowing what they may do means assume less.

collect-team-member — the manager path only​

collect-team-memberAgent node
GoalGet the code of the team member this manager is requesting the letter for.
Instructions
Ask which team member the letter is for, and get their employee code.
- Save it and finish on collected.
- If they can't produce a code after three attempts, finish on max_retry.
Do not accept a name in place of a code, and do not guess a code from a name.

"Do not guess a code from a name" is load-bearing. Without it, a helpful model will happily infer priya.s from "Priya" — and issue one employee's letter to another. The access check decided whether; this line stops the model from filling in the who.

collect-details — letter type and year​

collect-detailsAgent node
GoalCollect a valid letter type and year before anything is generated.
Instructions
Ask which letter they need (employment, salary, or visa) if it isn't
already clear, and which year.

Call @validateYear with the year they give.
- Valid: save the returned value to {{letter_year}} and finish on collected.
- Invalid: show the message @validateYear returned, and ask again.
- After three attempts, finish on max_retry.

Never accept a year @validateYear did not return as valid, and never write your
own error message for a rejected one.
Send no message on the turn you exit.

Validation is a workflow, not the model. The rule "never write your own error message" is what keeps the reason consistent — if HR changes which years are available, the workflow changes and every message follows. A model-authored error goes stale silently.

max_retry needs both halves. There is no retry counter anywhere in the platform. The branch exists and the instruction reaches it — remove either line and the node re-asks forever. See retry is not an exit kind.

Both variables are writable. Leaving them read-only is the most common variable mistake: nothing is written, nothing warns you, and the failure surfaces two nodes later when the generate step receives an empty year.

generate-and-deliver — do the work, then hand it over​

generate-and-deliverAgent node
GoalGenerate the letter and get it to them the way they asked for it.
Instructions
Ask how they'd like it: by email, or as a download link.

Call @generateLetter with the letter type, the year, and — only when a manager is
acting for a team member — the team member code. Pass only values collected in
this flow or returned by a validation workflow. Never one you inferred.

- Success, email: confirm it's on the way using @notify, and finish on delivered.
- Success, link: share the link and finish on delivered.
- Failure: apologise once, say you'll raise it with HR, and finish on
generation_failed. Never claim a letter was sent when it wasn't.

This is an Agent node, not an Execute Workflow node — it asks a question, so it needs a conversation. If it only generated and delivered with no choice to make, an Execute Workflow node would be cheaper and couldn't drift. That's the test: can you name what it would say?

generation_failed is separate from success on purpose. A document-service outage deserves an apology and a handover, not a pretend confirmation. And it's distinct from not_eligible back at verify — one is "we couldn't", the other is "you may not", and a customer can tell the difference even when a flow can't.

Variables this flow uses​

Worth drawing for any flow. It exposes unused variables and missing ones at a glance.

VariableScopeWritten byRead by
employeeglobalthe session-start hookverify, and every other journey
is_manager_allowedglobalaccess-checkgenerate-and-deliver
target_employee_codejourneycollect-team-membergenerate-and-deliver
letter_typejourneycollect-detailsgenerate-and-deliver
letter_yearjourneycollect-detailsgenerate-and-deliver
delivery_methodjourneygenerate-and-deliver—

Two things the table shows. Everything specific to this request is journey-scoped — nothing outside the flow reads a letter year, so making it global would be prompt weight on every turn of every other agent. And employee is global because four journeys read it. That's the whole rule: global if another agent needs it.

delivery_method has no reader. It's kept because it's genuinely useful in the logs when someone asks why a letter went to email — but if you find several of these, they're usually leftovers.

The unhappy paths​

What happensWhere it goesWhy that and not something else
Session-start lookup failednot_identified → HRNot the employee's fault, and not a denial
Letters not available on their contractnot_eligible → polite endA real business answer, so it deserves a real reply
Self-only employee names a colleaguestays on selfThe wiring never offers the path, so nothing to refuse
@accessCheck unavailableselfLeast privilege — narrow when unsure
No valid year after three triesmax_retry → HRWired somewhere different, never back into the same node
Manager can't produce a team codemax_retry → HRSame
Document service downgeneration_failed → HRAn outage, not a refusal
The run crashes or loopson_error → HRAlready there — it just needs wiring

Every one of those is wired. An unwired exit is a dead end: the conversation stops with no message and no next step.

Test it​

Walk the unhappy column, not the happy path — these are the cases that pass a demo and fail a real employee:

  • A self-only employee naming a colleague. Must refuse, and must not produce the colleague's letter.
  • Someone claiming "I'm their manager" when @accessCheck says otherwise. The claim must not be trusted.
  • A failed session-start lookup. Must reach a person, not a denial.
  • An invalid year, three times. max_retry must actually fire — if it doesn't, the instruction that reaches it is missing.
  • @generateLetter failing. Must apologise and hand over, never claim success.

The full set, and how to keep them as a regression suite, is in Prove it.


Next: The booking flow.