The flow cookbook
Common contact flow patterns, each a complete flowascode_contact_flow
resource for the Terraform provider. Every recipe in this directory was
applied to a live Amazon Connect instance on 2026-09-29 (Terraform 1.8.5,
provider v0.1.0), planned again with no changes, and destroyed.
tests/terraformProviderExample.test.ts holds, on every push, that each one
reads back through the HCL reader, is already terraform fmt output, lints
with no findings, and appears on this page exactly as it is in its file.
The recipes refer to their queues, hours and function by key
("queue:support"), and each binds its keys in refs to the resources in
supporting.tf. In your configuration, bind them to your own resources, data
sources or module variables; the flows do not change.
Two things every recipe gets right, because Connect refuses a flow that gets them wrong: each action wires the error branches its type requires (the provider's lint says which, at plan time), and each reference is a key, never an ARN.
To run them all: terraform apply in this directory, with
TF_VAR_connect_instance_id set and a customer-lookup.zip for the function.
Route by business hours
Check an hours of operation, transfer during them, and say so outside them. check_hours_of_operation takes exactly the two conditions True and False, plus the catch-all. Every transfer needs both QueueAtCapacity and NoMatchingError: Connect refuses a TransferContactToQueue without either.
business-hours.tf:
# Route to a queue during business hours; otherwise say so and hang up.
resource "flowascode_contact_flow" "business_hours" {
instance_id = var.connect_instance_id
name = "business-hours"
type = "CONTACT_FLOW"
refs = {
"hours:support" = aws_connect_hours_of_operation.support.arn
"queue:support" = aws_connect_queue.support.arn
}
action {
id = "check-hours"
next = "closed"
check_hours_of_operation {
hours_of_operation_id = "hours:support"
}
condition {
operator = "Equals"
operands = ["True"]
next = "set-queue"
}
condition {
operator = "Equals"
operands = ["False"]
next = "closed"
}
error {
type = "NoMatchingError"
next = "closed"
}
}
action {
id = "set-queue"
next = "transfer"
update_contact_target_queue {
queue_id = "queue:support"
}
error {
type = "NoMatchingError"
next = "closed"
}
}
action {
id = "transfer"
next = "closed"
transfer_contact_to_queue {}
error {
type = "QueueAtCapacity"
next = "closed"
}
error {
type = "NoMatchingError"
next = "closed"
}
}
action {
id = "closed"
next = "hang-up"
message_participant {
text = "We are closed. Our hours are nine to five, Monday to Friday."
}
error {
type = "NoMatchingError"
next = "hang-up"
}
}
action {
id = "hang-up"
disconnect_participant {}
}
}
A keypad menu
get_participant_input with store_input = "False" branches on the key pressed: one condition per key. That form needs InputTimeLimitExceeded (no key pressed) and NoMatchingCondition (a key no condition names) as well as the catch-all, and store_input must be written out; the conditional-shape rule holds all three.
keypad-menu.tf:
# A keypad menu. With StoreInput "False", Connect branches on the key pressed,
# and requires the InputTimeLimitExceeded branch (no key pressed) and the
# NoMatchingCondition branch (a key no condition names). StoreInput must be
# written out: the service refuses the action without it. The conditional-shape
# lint rule holds all three.
resource "flowascode_contact_flow" "keypad_menu" {
instance_id = var.connect_instance_id
name = "keypad-menu"
type = "CONTACT_FLOW"
refs = {
"queue:priority" = aws_connect_queue.priority.arn
"queue:support" = aws_connect_queue.support.arn
}
action {
id = "menu"
next = "not-understood"
get_participant_input {
text = "For support, press 1. For an existing case, press 2."
input_time_limit_seconds = 5
store_input = "False"
}
condition {
operator = "Equals"
operands = ["1"]
next = "to-support"
}
condition {
operator = "Equals"
operands = ["2"]
next = "to-priority"
}
error {
type = "InputTimeLimitExceeded"
next = "not-understood"
}
error {
type = "NoMatchingCondition"
next = "not-understood"
}
error {
type = "NoMatchingError"
next = "not-understood"
}
}
action {
id = "to-support"
next = "transfer"
update_contact_target_queue {
queue_id = "queue:support"
}
error {
type = "NoMatchingError"
next = "not-understood"
}
}
action {
id = "to-priority"
next = "transfer"
update_contact_target_queue {
queue_id = "queue:priority"
}
error {
type = "NoMatchingError"
next = "not-understood"
}
}
action {
id = "transfer"
next = "hang-up"
transfer_contact_to_queue {}
error {
type = "QueueAtCapacity"
next = "busy"
}
error {
type = "NoMatchingError"
next = "hang-up"
}
}
action {
id = "busy"
next = "hang-up"
message_participant {
text = "All of our agents are busy. Please call back later."
}
error {
type = "NoMatchingError"
next = "hang-up"
}
}
action {
id = "not-understood"
next = "hang-up"
message_participant {
text = "Sorry, we did not get that. Goodbye."
}
error {
type = "NoMatchingError"
next = "hang-up"
}
}
action {
id = "hang-up"
disconnect_participant {}
}
}
Collect digits and keep them
With store_input = "True" the same action stores what the caller types in $.StoredCustomerInput instead of branching on it. That form needs input_validation, takes no conditions, and refuses the NoMatchingCondition and InputTimeLimitExceeded branches. update_contact_attributes then copies the digits into a contact attribute the agent's screen can show.
collect-input.tf:
# Collect digits and keep them. With StoreInput "True", Connect stores the
# digits in $.StoredCustomerInput instead of branching on them: the action
# takes InputValidation, no conditions, and no NoMatchingCondition or
# InputTimeLimitExceeded branch (the conditional-shape lint rule holds all
# three).
resource "flowascode_contact_flow" "collect_input" {
instance_id = var.connect_instance_id
name = "collect-input"
type = "CONTACT_FLOW"
refs = {
"queue:support" = aws_connect_queue.support.arn
}
action {
id = "ask-account"
next = "keep-account"
get_participant_input {
text = "Please enter your eight digit account number."
input_time_limit_seconds = 8
store_input = "True"
input_validation = {
custom_validation = {
maximum_length = 8
}
}
}
error {
type = "NoMatchingError"
next = "to-support"
}
}
action {
id = "keep-account"
next = "to-support"
update_contact_attributes {
attributes = {
accountNumber = "$.StoredCustomerInput"
}
target_contact = "Current"
}
error {
type = "NoMatchingError"
next = "to-support"
}
}
action {
id = "to-support"
next = "transfer"
update_contact_target_queue {
queue_id = "queue:support"
}
error {
type = "NoMatchingError"
next = "hang-up"
}
}
action {
id = "transfer"
next = "hang-up"
transfer_contact_to_queue {}
error {
type = "QueueAtCapacity"
next = "hang-up"
}
error {
type = "NoMatchingError"
next = "hang-up"
}
}
action {
id = "hang-up"
disconnect_participant {}
}
}
Route on a Lambda lookup
Invoke a function with the caller's number, then branch on one key of what it returned. A STRING_MAP response lands in $.External, and compare takes any operator on any value; unmatched callers take NoMatchingCondition. The function must be associated with the instance (aws_connect_lambda_function_association in supporting.tf) or Connect cannot invoke it.
lambda-routing.tf:
# Look the caller up with a Lambda function, then route on what it returned.
# A STRING_MAP response lands in $.External, and Compare branches on one key.
resource "flowascode_contact_flow" "lambda_routing" {
instance_id = var.connect_instance_id
name = "lambda-routing"
type = "CONTACT_FLOW"
refs = {
"lambda:customer-lookup" = aws_lambda_function.customer_lookup.arn
"queue:priority" = aws_connect_queue.priority.arn
"queue:support" = aws_connect_queue.support.arn
}
action {
id = "look-up"
next = "check-tier"
invoke_lambda_function {
lambda_function_arn = "lambda:customer-lookup"
invocation_time_limit_seconds = 8
invocation_type = "SYNCHRONOUS"
lambda_invocation_attributes = {
phone = "$.CustomerEndpoint.Address"
}
response_validation = {
response_type = "STRING_MAP"
}
}
error {
type = "NoMatchingError"
next = "to-support"
}
}
action {
id = "check-tier"
next = "to-support"
compare {
comparison_value = "$.External.tier"
}
condition {
operator = "Equals"
operands = ["priority"]
next = "to-priority"
}
error {
type = "NoMatchingCondition"
next = "to-support"
}
}
action {
id = "to-priority"
next = "transfer"
update_contact_target_queue {
queue_id = "queue:priority"
}
error {
type = "NoMatchingError"
next = "to-support"
}
}
action {
id = "to-support"
next = "transfer"
update_contact_target_queue {
queue_id = "queue:support"
}
error {
type = "NoMatchingError"
next = "hang-up"
}
}
action {
id = "transfer"
next = "hang-up"
transfer_contact_to_queue {}
error {
type = "QueueAtCapacity"
next = "hang-up"
}
error {
type = "NoMatchingError"
next = "hang-up"
}
}
action {
id = "hang-up"
disconnect_participant {}
}
}
Offer a callback when the queue is full
The transfer's QueueAtCapacity branch leads to a callback instead of a busy message. update_contact_callback_number has no catch-all but two named errors, both required; create_callback_contact queues the callback with its delays and attempts.
callback-when-busy.tf:
# Offer a callback when the queue is full: the QueueAtCapacity branch sets the
# callback number to the number the caller dialed from and queues a callback.
resource "flowascode_contact_flow" "callback_when_busy" {
instance_id = var.connect_instance_id
name = "callback-when-busy"
type = "CONTACT_FLOW"
refs = {
"queue:support" = aws_connect_queue.support.arn
}
action {
id = "set-queue"
next = "transfer"
update_contact_target_queue {
queue_id = "queue:support"
}
error {
type = "NoMatchingError"
next = "sorry"
}
}
action {
id = "transfer"
next = "hang-up"
transfer_contact_to_queue {}
error {
type = "QueueAtCapacity"
next = "set-callback-number"
}
error {
type = "NoMatchingError"
next = "sorry"
}
}
action {
id = "set-callback-number"
next = "queue-callback"
update_contact_callback_number {
callback_number = "$.CustomerEndpoint.Address"
}
error {
type = "InvalidCallbackNumber"
next = "sorry"
}
error {
type = "CallbackNumberNotDialable"
next = "sorry"
}
}
action {
id = "queue-callback"
next = "confirm"
create_callback_contact {
queue_id = "queue:support"
initial_call_delay_seconds = 5
maximum_connection_attempts = 3
retry_delay_seconds = 600
}
error {
type = "NoMatchingError"
next = "sorry"
}
}
action {
id = "confirm"
next = "hang-up"
message_participant {
text = "All of our agents are busy. We will call you back at this number."
}
error {
type = "NoMatchingError"
next = "hang-up"
}
}
action {
id = "sorry"
next = "hang-up"
message_participant {
text = "Sorry, we cannot take your call right now. Please try again later."
}
error {
type = "NoMatchingError"
next = "hang-up"
}
}
action {
id = "hang-up"
disconnect_participant {}
}
}
Send a share of callers down a new path
distribute_by_percentage draws a number from 1 to 100. Each condition takes the callers below its bound, in order, and NoMatchingCondition takes the rest: here 20% hear the new greeting. Use it to roll out a change to part of your traffic before all of it.
percentage-split.tf:
# Send a share of callers down a new path. DistributeByPercentage draws a
# number from 1 to 100; each condition takes the callers below its bound, in
# order, and NoMatchingCondition takes the rest. Here: 20% new, 80% current.
resource "flowascode_contact_flow" "percentage_split" {
instance_id = var.connect_instance_id
name = "percentage-split"
type = "CONTACT_FLOW"
action {
id = "split"
next = "current-greeting"
distribute_by_percentage {}
condition {
operator = "NumberLessThan"
operands = ["21"]
next = "new-greeting"
}
error {
type = "NoMatchingCondition"
next = "current-greeting"
}
}
action {
id = "new-greeting"
next = "hang-up"
message_participant {
text = "Welcome. You are hearing our new greeting."
}
error {
type = "NoMatchingError"
next = "hang-up"
}
}
action {
id = "current-greeting"
next = "hang-up"
message_participant {
text = "Welcome."
}
error {
type = "NoMatchingError"
next = "hang-up"
}
}
action {
id = "hang-up"
disconnect_participant {}
}
}
A module several flows share
A flowascode_contact_flow_module is written like a flow, without type, and ends in end_flow_module_execution.
greeting-module.tf:
# A flow module several flows share. Flows invoke it through an alias, so a
# change to the module reaches them only when the alias moves (see
# module-release.tf), not the moment the module's content changes.
resource "flowascode_contact_flow_module" "greeting" {
instance_id = var.connect_instance_id
name = "greeting"
action {
id = "greet"
next = "done"
message_participant {
text = "Thanks for calling. Calls may be recorded for quality."
}
error {
type = "NoMatchingError"
next = "done"
}
}
action {
id = "done"
end_flow_module_execution {}
}
}
Release the module through an alias
Flows invoke the module through an alias, which points at a version. The version is keyed to the module's content_hash, so a content change replaces it; create_before_destroy makes the new version exist before the old one goes, and the alias moves to it in place in between. Connect refuses to delete a version an alias still points at. This is the shape flow-cli emit --target flowascode writes for a module a flow in the same set invokes by alias, and, with --module-alias module:greeting@live, for a module released on its own that nothing in the set invokes, with one difference: the emitted version and alias carry no description. A root that already holds this file by hand, descriptions and all, and switches to the emitted resources sees a plan that drops the descriptions (an in-place update, not a replacement); keep the hand-written resources if the descriptions matter, or accept the change. outputs.tf then carries greeting_live_arn for another root to bind.
module-release.tf:
# A version is a snapshot of the module's current content, replaced whenever
# that content changes. create_before_destroy makes the new version exist
# before the old one goes, and the alias moves to it in place in between:
# Connect refuses to delete a version an alias still points at.
resource "flowascode_contact_flow_module_version" "greeting" {
instance_id = var.connect_instance_id
contact_flow_module_id = flowascode_contact_flow_module.greeting.contact_flow_module_id
content_hash = flowascode_contact_flow_module.greeting.content_hash
description = "Greeting as reviewed"
lifecycle {
create_before_destroy = true
}
}
# The alias is what flows bind: its arn is the module ARN qualified by the
# alias id, the only form Connect runs as the alias.
resource "flowascode_contact_flow_module_alias" "greeting_live" {
instance_id = var.connect_instance_id
contact_flow_module_id = flowascode_contact_flow_module.greeting.contact_flow_module_id
name = "live"
contact_flow_module_version = flowascode_contact_flow_module_version.greeting.version
description = "The version callers hear"
}
Invoke the module from a flow
The flow binds module:greeting@live to the alias's arn, which is the module ARN qualified by the alias id: the only form Connect runs as the alias. A bare module ARN would run the module's latest content, not the released version.
invoke-module.tf:
# A flow that runs the shared greeting through its live alias, then continues.
resource "flowascode_contact_flow" "invoke_module" {
instance_id = var.connect_instance_id
name = "invoke-module"
type = "CONTACT_FLOW"
refs = {
"module:greeting@live" = flowascode_contact_flow_module_alias.greeting_live.arn
"queue:support" = aws_connect_queue.support.arn
}
action {
id = "greeting"
next = "set-queue"
invoke_flow_module {
flow_module_id = "module:greeting@live"
}
error {
type = "NoMatchingError"
next = "set-queue"
}
}
action {
id = "set-queue"
next = "transfer"
update_contact_target_queue {
queue_id = "queue:support"
}
error {
type = "NoMatchingError"
next = "hang-up"
}
}
action {
id = "transfer"
next = "hang-up"
transfer_contact_to_queue {}
error {
type = "QueueAtCapacity"
next = "hang-up"
}
error {
type = "NoMatchingError"
next = "hang-up"
}
}
action {
id = "hang-up"
disconnect_participant {}
}
}
Use an action the tooling does not model
A generic block carries any action type as its Flow language Type and its Parameters as jsonencode(...), unchanged. The provider checks nothing inside it beyond what Connect checks, so prefer a typed block where one exists. Connect decides which error branches this action takes from its parameters: with ContinueFlowExecution "True" these three, with "False" only the catch-all.
unmodeled-action.tf:
# An action type the tooling does not model yet still has a home: a generic
# block carries its Type and its Parameters as JSON, unchanged. Nothing about it
# is checked beyond what Connect checks, so prefer a typed block when one
# exists; the catalog lists which do. Connect decides which error branches this
# action takes from its parameters: with ContinueFlowExecution "True" it takes
# these three, with "False" only the catch-all (sandbox, 2026-09-29).
resource "flowascode_contact_flow" "unmodeled_action" {
instance_id = var.connect_instance_id
name = "unmodeled-action"
type = "CONTACT_FLOW"
action {
id = "desk-phone"
next = "hang-up"
generic {
type = "TransferParticipantToThirdParty"
parameters = jsonencode({
ContinueFlowExecution = "True"
ThirdPartyConnectionTimeLimitSeconds = "30"
ThirdPartyPhoneNumber = "+15555550100"
})
}
error {
type = "CallFailed"
next = "hang-up"
}
error {
type = "ConnectionTimeLimitExceeded"
next = "hang-up"
}
error {
type = "NoMatchingError"
next = "hang-up"
}
}
action {
id = "hang-up"
disconnect_participant {}
}
}