Idiomatic patterns for common Agent Script tasks. Each pattern shows the canonical shape, when to apply it, and what to watch for. Patterns are distilled from the Salesforce developer guide and the agent-script-recipes sample app.
- Topic Selector / Routing (start_agent block)
- Identity Verification
- Slot Filling
- Action Chaining and Sequencing
- Fetch Data Before Reasoning
- Conditional Prompting
- Transitions
- Filtering with available_when
- System Instruction Overrides
- Required Subagent Workflow
- Resource References in Prompts
- Variables for State Across Turns
The start_agent block runs at the start of every customer turn. It is the
agent's router. Keep it focused on classification + routing — no business
logic, minimal prompting.
start_agent topic_selector:
description: Routes the customer to the appropriate subagent.
reasoning:
instructions:
| Determine what the customer is asking about and route them to the
| correct topic. If the request is unclear, ask one short clarifying
| question. If the request is out of scope (medical, legal, financial
| advice), politely decline.
actions:
go_to_orders:
tool: @utils.transition to @subagent.order_management
description: Order tracking, status, modifications, cancellations.
go_to_billing:
tool: @utils.transition to @subagent.billing
description: Invoices, payments, refunds, payment methods.
go_to_returns:
tool: @utils.transition to @subagent.returns
description: Return requests, refund status, exchange policies.
Why it works: The LLM sees three clearly distinct tools with non-overlapping descriptions. The prompt explicitly delegates classification to the LLM rather than forcing keyword matching.
Watch for: descriptions that overlap ("orders" and "order returns") cause inconsistent routing. Use distinct vocabulary.
A reusable pattern: capture email, send a code, verify it. Combines deterministic logic for the security-critical steps with LLM-driven conversation for everything else.
subagent identity:
description: Verifies the customer's identity via email verification code.
reasoning:
instructions:
-> if @variables.customer_email is None
| Ask the customer for their email address. Wait for their reply.
-> if @variables.customer_email is not None
-> if @variables.code_sent == False
-> run @actions.send_verification_code with email = @variables.customer_email
-> set @variables.code_sent to True
| I just sent a verification code to {!@variables.customer_email}. Please share it with me.
actions:
capture_email:
tool: @utils.setVariables
description: |
Record the customer's email address when they provide it. Set
@variables.customer_email.
verify_code:
tool: @actions.verify_code
description: Use when the customer provides the verification code they received.
actions:
send_verification_code:
target: apex://SendVerificationCode
inputs:
email:
type: string
required: True
verify_code:
target: apex://VerifyCode
description: Verifies a code the customer provides against the one we sent.
inputs:
code:
type: string
required: True
outputs:
is_valid:
type: boolean
Why it works: Sending the code is gated by deterministic logic so it happens exactly once. Capturing the email and verifying the code are LLM- driven because both depend on parsing free-form customer input.
Watch for: repeating if @variables.customer_email is not None blocks
gets verbose fast. Once your subagent has more than two nested ifs,
consider splitting into smaller subagents or moving steps to
before_reasoning.
Let the LLM extract structured data from free-form customer input. Two mechanisms, depending on whether the extracted value should drive a downstream action or just be captured as state.
The ... token in reasoning.actions[].inputs tells the LLM to extract that
input from the conversation when it decides to call the tool:
variables:
first_name:
type: string
mutable: True
last_name:
type: string
mutable: True
reasoning:
actions:
capture_user_info:
tool: @actions.capture_user_info
description: Capture the customer's first and last name.
inputs:
first_name: ...
last_name: ...
instructions:
| Greet the customer warmly and ask for their first and last name. Use
| {!@actions.capture_user_info} to record what they provide.
actions:
capture_user_info:
description: Stores the customer's name for use across the conversation.
inputs:
first_name:
type: string
required: True
last_name:
type: string
required: True
target: apex://CaptureUserInfo
When you just need to capture state (no domain action runs), wrap
@utils.setVariables in a tool. The variable's description is the
extraction instruction:
variables:
shipping_address:
type: string
mutable: True
description: |
The customer's full shipping address, including street, city, state,
and zip code.
reasoning:
actions:
capture_address:
tool: @utils.setVariables
description: Record the shipping address when the customer provides it.
Why it works: the variable's description (or the action input's name)
is the slot-filling instruction. The LLM reads it, extracts the value from
the conversation, and binds it.
Watch for: vague descriptions yield vague extractions. Be explicit about
format: "including street, city, state, and zip code" gives the LLM a
checklist. The ... token is only valid in action inputs — never use it
as a variable default.
When N actions must run in a fixed order, do not expose them as independent tools. The LLM might call them out of order, or skip one.
reasoning:
instructions:
-> run @actions.lookup_account with customer_id = @variables.customer_id
-> set @variables.account_status = @outputs.status
-> set @variables.account_balance = @outputs.balance
-> if @variables.account_status == "active"
-> run @actions.calculate_eligible_offers with balance = @variables.account_balance
-> set @variables.offers = @outputs.offer_list
| Walk the customer through their available offers in
| {!@variables.offers}.
[Unverified] If the chain starts from an LLM-callable tool but the follow-up
must run deterministically, Salesforce documentation describes a follow-up
property on the reasoning action — likely then:. Verify the exact spelling
in the Action Chaining pattern doc
before using it in production.
If in doubt, prefer the deterministic chain in logic (above) — it is unambiguous and always works.
Watch for: "the LLM forgot to call action B after action A" is the canonical sign you needed a chain, not two independent tools.
When the LLM's response depends on data that must be loaded first, fetch in
before_reasoning so the prompt has the data already resolved.
subagent order_status:
before_reasoning:
-> run @actions.get_order_details with order_id = @variables.order_id
-> set @variables.order_status = @outputs.status
-> set @variables.eta = @outputs.estimated_delivery
reasoning:
instructions:
| Tell the customer their order is currently {!@variables.order_status}
| and is expected to arrive on {!@variables.eta}.
Why it works: the action runs once, deterministically, before the prompt is assembled. The LLM does not get the chance to "decide" whether to look up the order — it has to.
Watch for: putting expensive lookups in before_reasoning even when the
data is not always needed. If only some branches need the data, fetch inside
the relevant if.
Different prompt content depending on state.
reasoning:
instructions:
-> if @variables.is_premium_member == True
| Greet the customer warmly and mention their VIP status. Offer them
| priority handling.
-> else
| Greet the customer politely. If they ask about benefits, mention that
| premium membership unlocks priority handling.
Why it works: business rules (membership state) live in logic; tone and phrasing live in prompt instructions.
Watch for: the antipattern is the inverse — embedding the rule in the
prompt: | If they're premium, say X, otherwise Y. The LLM may misclassify;
logic gives you certainty.
From inside reasoning instructions:
reasoning:
instructions:
-> run @actions.check_eligibility
-> set @variables.is_eligible = @outputs.is_eligible
-> if @variables.is_eligible == False
-> transition to @subagent.ineligible_handler
| The customer is eligible. Walk them through enrollment.
The transition fires immediately; everything after it in the topic is discarded.
Expose the transition as a tool in reasoning.actions:
reasoning:
actions:
transfer_to_billing:
tool: @utils.transition to @subagent.billing
description: Use when the customer's question is about invoices or payments.
The LLM decides whether and when to call it.
Use after_reasoning to transition based on a final condition:
subagent order_lookup:
reasoning:
instructions:
| Help the customer find their order. Use the lookup tool if they
| provide an order number.
actions:
lookup:
tool: @actions.get_order_by_number
after_reasoning:
-> if @variables.lookup_failed == True
-> transition to @subagent.escalation
Watch for: transitions are one-way. Anything you needed to communicate to the customer must already be in the resolved prompt of the current topic before the transition fires. After the transition, only the destination topic's prompt reaches the LLM.
Hide a tool or topic from the LLM when a precondition is not met.
reasoning:
actions:
cancel_order:
tool: @actions.cancel_order
description: Cancels the order if eligible.
available_when: |
@variables.is_verified == True and
@variables.order_status == "pending"
Why it works: the LLM never sees the tool when the condition is false, so there is no risk of it being called inappropriately. The agent does not need to maintain "if not allowed, refuse" instructions in the prompt.
Watch for: complex available_when expressions become hard to debug. If
you have more than two clauses, consider hoisting the check into a variable
set in before_reasoning.
System-level instructions (in the global system block) apply to every
subagent. A subagent-level system.instructions override replaces them for
that subagent.
This avoids contradictory instructions reaching the LLM.
system:
instructions: |
You are a friendly retail assistant for ACME Corp. Always speak casually
and use first names.
subagent legal_disclosure:
system:
instructions: |
You must speak formally and precisely. Never use first names. Read the
customer the disclosure exactly as written; do not paraphrase.
description: Reads the legally required disclosure to the customer.
Why it works: the override scopes the new tone to the one subagent where it matters, without polluting the rest of the agent.
Watch for: if you find yourself overriding system.instructions in
most subagents, the global system.instructions is probably too prescriptive
— pull it back to the truly universal rules.
Force the customer through a required step before allowing other capabilities.
start_agent topic_selector:
reasoning:
instructions:
-> if @variables.is_verified == False
-> transition to @subagent.identity
| Route the customer to the appropriate topic.
actions:
go_to_orders:
tool: @utils.transition to @subagent.order_management
description: For order tracking and modifications.
available_when: @variables.is_verified == True
go_to_billing:
tool: @utils.transition to @subagent.billing
description: For billing questions.
available_when: @variables.is_verified == True
After identity verification completes (and presumably sets
@variables.is_verified to True), subsequent turns hit the start_agent again
and skip the forced redirect.
Watch for: the verification topic must explicitly set the gating variable. A common bug is verifying the user but never flipping the flag, causing an infinite redirect loop.
Explicitly reference variables, actions, and subagents in prompt instructions to give the LLM more context.
reasoning:
instructions:
| The customer has placed {!@variables.order_count} orders this year and
| their loyalty tier is {!@variables.loyalty_tier}. If they ask about
| rewards, use {!@actions.get_loyalty_balance} to look up their points.
The bang-brace form interpolates the value at prompt-resolution time. The LLM sees the actual numbers and the tool reference.
Why it works: "@-mentioning" tools in the prompt increases the chance the LLM calls them when intended.
Watch for: over-referencing makes prompts noisy and harder for the LLM to parse. Reference the resources that genuinely guide the LLM's decision; trust the LLM to find the rest from descriptions.
Variables are the agent's reliable memory between turns. The LLM remembers the conversation, but for anything that drives logic, use a variable.
variables:
num_failed_attempts: mutable number = 0
is_verified: mutable boolean = False
current_order_id: mutable string
subagent identity:
reasoning:
instructions:
-> run @actions.verify_code with code = @variables.code_attempt
-> set @variables.is_valid = @outputs.is_valid
-> if @variables.is_valid == True
-> set @variables.is_verified to True
| Verification successful. How can I help?
-> else
-> set @variables.num_failed_attempts to @variables.num_failed_attempts + 1
-> if @variables.num_failed_attempts >= 3
-> transition to @subagent.escalation
| That code didn't match. Want me to send another?
Why it works: the failed-attempts counter is reliable across turns. The LLM does not need to "remember" how many tries the customer has had — the variable does.
Watch for: mutable variables let the LLM (with description) and your
logic both write. If both can write, define clear ownership in your design,
or you'll chase race-style bugs.