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.
eligibleaccess-checknot_eligibleEnd (business)not_identifiedTransfer to HR
Agentaccess-check
Decide whether they may act on another employee's record, or only their own.
@accessCheck
selfcollect-detailsmanagercollect-team-member
Agentcollect-team-member
Get the team member code the manager is requesting for.
collectedcollect-detailsmax_retryTransfer to HR
Agentcollect-details
Collect a valid letter type and year.
@validateYear
collectedgenerate-and-delivermax_retryTransfer to HR
Agentgenerate-and-deliver
Generate the letter and get it to them the way they want it.
@generateLetter@notify
deliveredEndgeneration_failedTransfer 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.