Conversation Process Best Practices

Four conversation process anti-patterns that break in production and how to fix them.

View as Markdown

Conversation processes wire together slots and actions into multi-turn flows. The patterns here are the ones that pass local testing but break once real users and real data hit them. Each one looks harmless in the editor — the damage shows up in production.

The YAML examples on this page represent conversation process configurations. In practice, you build these in the Agent Studio visual editor — the YAML is the underlying representation of what the editor produces.

Looking for the chained-actions pattern? That’s covered in The Golden Rule — the single most important architecture principle in Agent Studio.

Async Output Referenced Later

This is the most common silent failure in conversation processes. It passes every test because timing works differently in the editor.

Only compound actions can run asynchronously — wait_execution is a compound action setting. When you use a compound action as an activity inside a conversation process, it defaults to synchronous (wait_execution: true). Setting it to false makes it fire-and-forget.

What it looks like

Referencing async output downstream
1# Activity 9: Log analytics (fire-and-forget)
2- action:
3 action_name: log_analytics
4 wait_execution: false
5 output_key: analytics_result
6
7# Activity 11: Send notification with the tracking ID
8- action:
9 action_name: send_status_notification
10 output_mapper:
11 tracking_id: data.analytics_result.tracking_id # BROKEN

Why it breaks

When wait_execution: false:

  1. The action is dispatched to the backend
  2. Execution continues immediately to the next activity
  3. The output is empty — the action hasn’t completed yet

You can’t reference output from an async action because it doesn’t exist at the time the next activity runs.

Real consequence

data.analytics_result.tracking_id resolves to undefined. The output mapper fails silently or returns null. The user sees a broken response with a missing tracking ID — or the entire downstream activity fails with no useful error message.

Scenario A: The Bug (Async)
Activity 9: Log Analytics
wait_execution: false
execution continues immediately...
data.analytics_result
∅ empty
Activity 11: Read Output
tracking_id: undefined
Output referenced before it exists
Scenario B: The Fix (Sync)
Activity 9: Log Analytics
wait_execution: true
waiting for completion...
data.analytics_result
tracking_id: TRK-001
Activity 11: Read Output
tracking_id: TRK-001
Output available because execution waited
Step 0 of 10

The fix

Either make it synchronous if you need the output:

Synchronous execution
1- action:
2 action_name: log_analytics
3 wait_execution: true # Wait for completion
4 output_key: analytics_result

Or don’t reference the output if you truly want fire-and-forget:

Fire-and-forget (no output reference)
1- action:
2 action_name: log_analytics
3 wait_execution: false
4 # No output_key — nothing to reference later

Rule of thumb: If any later activity reads data.<output_key>.* from a compound action, that compound action must have wait_execution: true. Async is only for side effects you never need to read downstream.


No Confirmation on Destructive Actions

The reasoning engine interprets user intent. It does not always interpret it correctly. Without a confirmation step, there’s no checkpoint between “engine decided” and “action executed.”

What it looks like

Destructive action without confirmation
1- action:
2 action_name: delete_user_account
3 confirmation_policy: false
4
5- action:
6 action_name: create_calendar_event
7 confirmation_policy: false

Why it breaks

Users are ambiguous. The reasoning engine resolves ambiguity with its best guess. Without confirmation:

  • The wrong item gets deleted
  • A meeting is scheduled for the wrong time or wrong people
  • There’s no chance to catch the error before an irreversible action executes

Real consequence

User: “Delete my old project” Engine interprets “old project” as “Current Project” (the most recently discussed one). Action: Deletes Current Project. User: “Wait, not that one!” Too late. Data gone. No undo.

Scenario A: Without Confirmation
"Delete my old project"
Reasoning Engineselects "Current Project"
wrong match
delete_project
No confirmation checkpoint
Wrong project deleted. Data gone. No undo.
Scenario B: With Confirmation
"Delete my old project"
Delete "Current Project"? This cannot be undone.
No
Yes
"No, I meant 'Legacy Project v1'"
Delete "Legacy Project v1"?
Confirmed
Correct project deleted. Crisis averted.
Step 0 of 10

The fix

Confirmation on destructive actions
1- action:
2 action_name: delete_user_account
3 confirmation_policy: true # "Are you sure?"
4
5- action:
6 action_name: create_calendar_event
7 confirmation_policy: true # "Does this look right?"

Rule of thumb: Any action that creates, updates, or deletes data should have confirmation_policy: true. The only exception is read-only lookups and logging side effects.


system_instructions in Output Mapper

This one is tempting. Your action returns a big response payload and you only want to show a few fields to the user. So you ask the LLM to pick them out. The problem is that output mappers are supposed to return structured data, not delegate formatting to an LLM.

What it looks like

Say your HTTP action returns this payload:

Sample API response
1{
2 "ticket": {
3 "id": "INC-4821",
4 "status": "open",
5 "priority": "high",
6 "assignee": {
7 "name": "Jane Kim",
8 "email": "jane.kim@company.com",
9 "team": "Infrastructure"
10 },
11 "created_at": "2026-03-15T14:30:00Z",
12 "updated_at": "2026-03-16T09:12:00Z",
13 "sla_breach_at": "2026-03-17T14:30:00Z",
14 "description": "Production database connection pool exhausted...",
15 "comments": [...]
16 }
17}

You only want the user to see the ticket ID, status, assignee, and SLA deadline. Instead of mapping those fields directly, you use system_instructions to ask the LLM to extract them:

LLM call inside output mapper
1output_mapper:
2 summary:
3 system_instructions: "Show only the ticket ID, current status, who it's assigned to, and the SLA deadline from the response."

Why it breaks

system_instructions triggers an LLM call to generate the value. This is:

  1. Non-deterministic — different output each time, different field ordering, different phrasing
  2. Slow — adds an LLM round-trip to every execution
  3. Expensive — burns tokens on formatting instead of reasoning
  4. Unreliable — the LLM might hallucinate fields, omit fields, or reformat dates incorrectly

Real consequence

Sometimes the user sees: “Ticket INC-4821, Status: Open, Assigned to Jane Kim, SLA: March 17” Sometimes: “Here’s a summary: The ticket is open and assigned to Jane from Infrastructure.” Sometimes: The LLM includes the full description you didn’t want, or drops the SLA field entirely.

The output is different every time. You can’t build reliable downstream logic on non-deterministic data.

The fix

Map the exact fields you need directly:

Structured output mapper
1output_mapper:
2 ticket_id: data.ticket.id
3 status: data.ticket.status
4 assignee_name: data.ticket.assignee.name
5 sla_deadline: data.ticket.sla_breach_at

The reasoning engine already knows how to present structured data to users conversationally. That’s its job — don’t duplicate it in the output mapper with an LLM call.

When is system_instructions appropriate in an output mapper? Only for cosmetic adjustments that don’t affect the structural output — adjusting tone, adding formatting like bold or italics, or rephrasing a value for readability. If you’re selecting, filtering, or transforming fields from a response, use direct field mapping instead.


RENDER with Mustache Loops

Mustache templates in output mappers seem like a clean way to format lists. In practice, they break in ways that are hard to diagnose.

What it looks like

Mustache loop in output mapper
1output_mapper:
2 attendee_display: |
3 {{#each data.parsed_attendees}}
4 - {{this.name}}: {{#if this.available}}Available{{else}}Busy{{/if}}
5 {{/each}}

Why it breaks

  1. Mustache rendering is non-deterministic in this context
  2. The rendered output is only visible if the next activity has confirmation_policy: true
  3. Complex logic in templates is hard to debug — no error messages, just missing data

Real consequence

You expect:

Expected output
- John: Available
- Jane: Busy

You get:

Actual output
- : Available
- : Busy

Names are missing because context binding failed silently. Or the entire rendered string is empty because the next activity doesn’t have confirmation enabled.

The fix

Return structured data using a data mapper:

Structured data with MAP()
1output_mapper:
2 attendees_with_status:
3 MAP():
4 items: data.parsed_attendees
5 converter:
6 name: item.name
7 email: item.email
8 available: item.email IN data.availability.available

Let the reasoning engine format the list for display. It handles internationalization, markdown formatting, and conversational tone — things a Mustache template can’t adapt to.

Data mapper functions like MAP(), FILTER(), and SORT() handle iteration and transformation without the fragility of template rendering. See the Data Mapper in HTTP Actions reference and the decision frameworks guide for when to use DSL, data mappers, or Python.


Quick Reference

PatternSeveritySignalFix
Async output referenced laterCriticalwait_execution: false + output_key referenced downstreamSet wait_execution: true or don’t reference the output
No confirmation on destructive actionsHighconfirmation_policy: false on create/update/deleteSet confirmation_policy: true
system_instructions in output mapperMediumsystem_instructions key in any output_mapperReturn structured fields, let the reasoning engine format
RENDER + Mustache loopsMedium{{#each}} or {{#if}} in output mapper valuesUse MAP(), FILTER(), SORT() data mapper functions