Migrating a project between environments

This guide explains how to copy a SentiOne Automate project from one environment to another — for example, promoting a bot from a staging environment to production, or cloning a project into a customer's environment.

Migration runs entirely through the public API, driven by the migrate-project-between-envs.sh script. You provide the project's UUID and an API key for each environment; the script exports the project from the source and imports it into the target.

#!/usr/bin/env bash
#
# Migrate a project + the knowledge base between SentiOne Automate environments
# using the public API.
#
# Requires: bash >=4, curl, jq

set -euo pipefail

API_PREFIX="/api/public/v1"
DEFAULT_CONNECT_TIMEOUT=30
DEFAULT_MAX_TIME=1800
UUID_REGEX='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'

# --- helpers ---------------------------------------------------------------

show_header() {
    echo >&2
    echo "##########################################################" >&2
    echo >&2
    for msg in "$@"; do
        echo "$msg" >&2
    done
    echo >&2
    echo "##########################################################" >&2
    echo >&2
}

mask_key() {
    local key="$1"
    local len=${#key}
    if (( len <= 8 )); then
        echo "***"
    else
        echo "${key:0:6}…${key: -3}"
    fi
}

url_encode() {
    jq -rn --arg s "$1" '$s|@uri'
}

slugify_filename() {
    printf '%s' "$1" | LC_ALL=C tr -c 'A-Za-z0-9._-' '_'
}

is_valid_uuid() {
    [[ "$1" =~ $UUID_REGEX ]]
}

format_elapsed() {
    local s=$1
    if (( s >= 60 )); then
        printf '%dm %ds' "$((s / 60))" "$((s % 60))"
    else
        printf '%ds' "$s"
    fi
}

state_label() {
    case "$1" in
        ok)        echo "OK" ;;
        failed)    echo "FAILED" ;;
        skipped)   echo "skipped (global KB migration not requested; pass --migrate-global-kb)" ;;
        not_run)   echo "not run" ;;
        completed) echo "completed" ;;
        dry_run)   echo "dry run (no writes performed)" ;;
        *)         echo "$1" ;;
    esac
}

# --- summary + exit trap (installed before mktemp) -------------------------

START_SECONDS="$SECONDS"
SUMMARY_PROJECT_STATE="not_run"     # not_run | ok | failed | dry_run
SUMMARY_PROJECT_NAME=""
SUMMARY_KB_DOCS_STATE="not_run"     # not_run | skipped | completed | dry_run
SUMMARY_KB_DOCS_OK=()
SUMMARY_KB_DOCS_FAILED=()
SUMMARY_KB_ITEMS_STATE="not_run"    # not_run | skipped | ok | failed | dry_run
SUMMARY_KB_ITEMS_ADDED="?"
SUMMARY_KB_ITEMS_UPDATED="?"
SUMMARY_KB_ITEMS_UNCHANGED="?"

PROJECT_UUID=""
TARGET_PROJECT_UUID=""
WORKDIR=""
CLEANUP_WORKDIR="false"

print_summary() {
    local elapsed=$(( SECONDS - START_SECONDS ))
    {
        echo
        echo "##########################################################"
        echo "Summary"
        echo "##########################################################"
        echo

        local proj_label
        proj_label="$(state_label "$SUMMARY_PROJECT_STATE")"
        if [[ -n "$SUMMARY_PROJECT_NAME" ]]; then
            proj_label="$proj_label (\"$SUMMARY_PROJECT_NAME\")"
        fi
        printf 'Project       %s  ->  %s   %s\n' \
            "${PROJECT_UUID:-?}" "${TARGET_PROJECT_UUID:-?}" "$proj_label"

        case "$SUMMARY_KB_DOCS_STATE" in
            completed)
                local total=$(( ${#SUMMARY_KB_DOCS_OK[@]} + ${#SUMMARY_KB_DOCS_FAILED[@]} ))
                local failed_str=""
                if (( ${#SUMMARY_KB_DOCS_FAILED[@]} > 0 )); then
                    failed_str=", failed: $(printf '%s, ' "${SUMMARY_KB_DOCS_FAILED[@]}" | sed 's/, $//')"
                fi
                printf 'KB documents  %d total, %d OK, %d failed%s\n' \
                    "$total" \
                    "${#SUMMARY_KB_DOCS_OK[@]}" \
                    "${#SUMMARY_KB_DOCS_FAILED[@]}" \
                    "$failed_str"
                ;;
            *)
                printf 'KB documents  %s\n' "$(state_label "$SUMMARY_KB_DOCS_STATE")"
                ;;
        esac

        case "$SUMMARY_KB_ITEMS_STATE" in
            ok)
                printf 'KB items      added %s, updated %s, unchanged %s\n' \
                    "$SUMMARY_KB_ITEMS_ADDED" \
                    "$SUMMARY_KB_ITEMS_UPDATED" \
                    "$SUMMARY_KB_ITEMS_UNCHANGED"
                ;;
            *)
                printf 'KB items      %s\n' "$(state_label "$SUMMARY_KB_ITEMS_STATE")"
                ;;
        esac

        if [[ -n "$WORKDIR" ]]; then
            local workdir_note=""
            if [[ "$CLEANUP_WORKDIR" == "true" ]]; then
                workdir_note=" (auto-cleaned)"
            fi
            printf 'Workdir       %s%s\n' "$WORKDIR" "$workdir_note"
        fi
        printf 'Elapsed       %s\n' "$(format_elapsed "$elapsed")"
        echo
        echo "##########################################################"
        echo
    } >&2
}

on_exit() {
    local rc=$?
    set +e
    print_summary
    if [[ "$CLEANUP_WORKDIR" == "true" && -n "$WORKDIR" ]]; then
        rm -rf "$WORKDIR"
    fi
    exit "$rc"
}

# --- usage -----------------------------------------------------------------

usage() {
    cat <<'EOF' >&2
Usage: migrate-between-envs.sh <project-uuid> --url URL --api-key KEY [options]

Copies one project (by UUID) from a source environment to a target
environment. The org-wide knowledge base (documents + items) is migrated
ONLY when --migrate-global-kb is passed — see the warning on that flag.

Required:
  <project-uuid>            UUID of the project to migrate.
  --url URL                 Source environment base URL (e.g. https://chatbots.example.com).
  --api-key KEY             Source environment API key (X-Api-Key header).

Target (optional — defaults to source for same-env runs):
  --target-url URL          Target environment base URL.
  --target-api-key KEY      Target environment API key.
  --target-project-id UUID  UUID to use for the project on the target
                            (defaults to <project-uuid>). Use a fresh UUID
                            when the source UUID is already taken on the
                            target by another tenant.

Options:
  --include-nlu-models      Include trained binary NLU models in the project export.
  --publish-flows           Publish all flows after import on the target.
  --migrate-global-kb       Also migrate the ORG-WIDE knowledge base (documents
                            + items). Off by default. WARNING: this is not
                            project-scoped — it overwrites the target org's
                            entire KB and is hard to roll back. Do not use
                            against a shared target that holds other tenants' KB.
  --fail-fast               Abort KB-doc loop on first per-doc failure.
  --dry-run                 Probe source/target metadata and print what *would*
                            be migrated. No writes to the target.
  --workdir DIR             Where to stage downloads (default: mktemp). The
                            script writes API-key auth files there at mode 600.
  --debug                   Log every HTTP request to stderr. API keys are
                            masked. WARNING: URLs may still be sensitive.
  -h, --help                Show this help.

Exit codes:
  0  success
  1  any failure (project import, KB doc/items import, network, etc.)
  2  invalid CLI usage
EOF
}

# --- argument parsing ------------------------------------------------------

SOURCE_URL=""
SOURCE_KEY=""
TARGET_URL=""
TARGET_KEY=""
INCLUDE_NLU_MODELS="false"
PUBLISH_FLOWS="false"
MIGRATE_GLOBAL_KB="false"
DEBUG="false"
DRY_RUN="false"
FAIL_FAST="false"

while [[ $# -gt 0 ]]; do
    case "$1" in
        -h|--help)
            usage
            exit 0
            ;;
        --url)              SOURCE_URL="$2";          shift 2 ;;
        --api-key)          SOURCE_KEY="$2";          shift 2 ;;
        --target-url)       TARGET_URL="$2";          shift 2 ;;
        --target-api-key)   TARGET_KEY="$2";          shift 2 ;;
        --target-project-id) TARGET_PROJECT_UUID="$2"; shift 2 ;;
        --include-nlu-models) INCLUDE_NLU_MODELS="true"; shift ;;
        --publish-flows)    PUBLISH_FLOWS="true";     shift ;;
        --migrate-global-kb) MIGRATE_GLOBAL_KB="true"; shift ;;
        --fail-fast)        FAIL_FAST="true";         shift ;;
        --dry-run)          DRY_RUN="true";           shift ;;
        --workdir)          WORKDIR="$2";             shift 2 ;;
        --debug)            DEBUG="true";             shift ;;
        -*)
            echo "Unknown option: $1" >&2
            usage
            exit 2
            ;;
        *)
            if [[ -z "$PROJECT_UUID" ]]; then
                PROJECT_UUID="$1"
            else
                echo "Unexpected positional argument: $1" >&2
                usage
                exit 2
            fi
            shift
            ;;
    esac
done

if [[ -z "$PROJECT_UUID" || -z "$SOURCE_URL" || -z "$SOURCE_KEY" ]]; then
    echo "Missing required argument: <project-uuid>, --url, or --api-key." >&2
    usage
    exit 2
fi

TARGET_URL="${TARGET_URL:-$SOURCE_URL}"
TARGET_KEY="${TARGET_KEY:-$SOURCE_KEY}"
TARGET_PROJECT_UUID="${TARGET_PROJECT_UUID:-$PROJECT_UUID}"

SOURCE_URL="${SOURCE_URL%/}"
TARGET_URL="${TARGET_URL%/}"

if ! is_valid_uuid "$PROJECT_UUID"; then
    echo "Invalid <project-uuid>: $PROJECT_UUID" >&2
    exit 2
fi
if ! is_valid_uuid "$TARGET_PROJECT_UUID"; then
    echo "Invalid --target-project-id: $TARGET_PROJECT_UUID" >&2
    exit 2
fi

if ! command -v jq >/dev/null 2>&1; then
    show_header "jq is required but not installed"
    exit 1
fi

# Install the exit trap BEFORE creating temporary state so a Ctrl-C between
# mktemp and the trap line can't leak a workdir.
trap on_exit EXIT

# --- workdir + auth files --------------------------------------------------

if [[ -z "$WORKDIR" ]]; then
    WORKDIR="$(mktemp -d -t migrate-between-envs-XXXXXX)"
    CLEANUP_WORKDIR="true"
else
    mkdir -p "$WORKDIR"
fi
chmod 700 "$WORKDIR" 2>/dev/null || true

SOURCE_AUTH_FILE="$WORKDIR/source-auth.hdr"
TARGET_AUTH_FILE="$WORKDIR/target-auth.hdr"
(
    umask 077
    printf 'X-Api-Key: %s\n' "$SOURCE_KEY" > "$SOURCE_AUTH_FILE"
    printf 'X-Api-Key: %s\n' "$TARGET_KEY" > "$TARGET_AUTH_FILE"
)

# --- curl plumbing ---------------------------------------------------------

CURL_COMMON_OPTS=(
    --silent --show-error --fail-with-body
    --connect-timeout "$DEFAULT_CONNECT_TIMEOUT"
    --max-time "$DEFAULT_MAX_TIME"
)

auth_label() {
    case "$1" in
        "$SOURCE_AUTH_FILE") echo "source" ;;
        "$TARGET_AUTH_FILE") echo "target" ;;
        *) echo "$(basename "$1" .hdr)" ;;
    esac
}

debug_log() {
    if [[ "$DEBUG" == "true" ]]; then
        echo "[debug] $*" >&2
    fi
}

curl_get_json() {
    local url="$1" auth_file="$2"
    debug_log "GET $url (auth: $(auth_label "$auth_file"))"
    curl "${CURL_COMMON_OPTS[@]}" \
        -H "@${auth_file}" \
        -H "Accept: application/json" \
        "$url"
}

curl_get_to_file() {
    local url="$1" auth_file="$2" out="$3"
    debug_log "GET $url -> $out (auth: $(auth_label "$auth_file"))"
    curl "${CURL_COMMON_OPTS[@]}" \
        -H "@${auth_file}" \
        -o "$out" \
        "$url"
}

curl_post_file() {
    local url="$1" auth_file="$2" file="$3"
    debug_log "POST $url (file=$file, auth: $(auth_label "$auth_file"))"
    curl "${CURL_COMMON_OPTS[@]}" \
        -H "@${auth_file}" \
        -F "file=@${file}" \
        "$url"
}

curl_put_doc() {
    local url="$1" auth_file="$2" file="$3" params_json="$4"
    local file_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
    [[ "$file" == *.csv ]] && file_type="text/csv"
    debug_log "PUT $url (file=$file [$file_type], params=$params_json, auth: $(auth_label "$auth_file"))"
    curl "${CURL_COMMON_OPTS[@]}" \
        -X PUT \
        -H "@${auth_file}" \
        -F "params=${params_json};type=application/json" \
        -F "file=@${file};type=${file_type}" \
        "$url"
}

print_response_body() {
    local file="$1"
    if [[ ! -s "$file" ]]; then
        echo "(empty response body)" >&2
        return
    fi
    if jq '.' "$file" >&2 2>/dev/null; then
        return
    fi
    local mime=""
    if command -v file >/dev/null 2>&1; then
        mime="$(file -b --mime-type "$file" 2>/dev/null || true)"
    fi
    case "$mime" in
        text/*|application/json*|application/xml*|application/*+json*|"")
            cat "$file" >&2
            ;;
        *)
            echo "(non-text response: ${mime}, $(wc -c <"$file") bytes — kept at $file)" >&2
            ;;
    esac
}

# --- stage 1: AI credentials ----------------------------------------------

show_header "Fetching AI credentials from source and target environments"

SOURCE_CREDS_JSON="$WORKDIR/source-credentials.json"
TARGET_CREDS_JSON="$WORKDIR/target-credentials.json"

if ! curl_get_json "$SOURCE_URL$API_PREFIX/organization/ai-credentials" "$SOURCE_AUTH_FILE" >"$SOURCE_CREDS_JSON"; then
    echo "ERROR: failed to fetch source AI credentials. Server response:" >&2
    print_response_body "$SOURCE_CREDS_JSON"
    exit 1
fi
if ! curl_get_json "$TARGET_URL$API_PREFIX/organization/ai-credentials" "$TARGET_AUTH_FILE" >"$TARGET_CREDS_JSON"; then
    echo "ERROR: failed to fetch target AI credentials. Server response:" >&2
    print_response_body "$TARGET_CREDS_JSON"
    exit 1
fi

src_credential_name_for_id() {
    jq -r --arg id "$1" '.[] | select(.id == $id) | .name' "$SOURCE_CREDS_JSON"
}

target_credential_id_for_name() {
    local name="$1"
    local matches
    matches="$(jq -r --arg name "$name" '[.[] | select(.name == $name) | .id]' "$TARGET_CREDS_JSON")"
    local count
    count="$(jq 'length' <<<"$matches")"
    if [[ "$count" == "1" ]]; then
        jq -r '.[0]' <<<"$matches"
    elif [[ "$count" -gt "1" ]]; then
        echo "WARN: target has $count credentials named \"$name\" — picking the first; rename duplicates on the target to disambiguate." >&2
        jq -r '.[0]' <<<"$matches"
    fi
    # count == 0 → echo nothing
}

# --- dry-run short-circuit ------------------------------------------------

if [[ "$DRY_RUN" == "true" ]]; then
    show_header "Dry run — no writes to target"

    PROJECT_DETAILS_JSON="$WORKDIR/project-details.json"
    if ! curl_get_json "$SOURCE_URL$API_PREFIX/projects/$PROJECT_UUID" "$SOURCE_AUTH_FILE" >"$PROJECT_DETAILS_JSON"; then
        echo "ERROR: failed to fetch source project. Server response:" >&2
        print_response_body "$PROJECT_DETAILS_JSON"
        exit 1
    fi
    dry_project_name="$(jq -r '.name // "<unknown>"' "$PROJECT_DETAILS_JSON")"
    dry_flow_count="$(jq -r '(.flows // []) | length' "$PROJECT_DETAILS_JSON")"
    dry_nlu_count="$(jq -r '(.nlus // []) | length' "$PROJECT_DETAILS_JSON")"

    echo "Project        $PROJECT_UUID  ->  $TARGET_PROJECT_UUID" >&2
    echo "               \"$dry_project_name\"  (${dry_flow_count} flows, ${dry_nlu_count} NLUs)" >&2

    if [[ "$MIGRATE_GLOBAL_KB" == "true" ]]; then
        echo >&2
        echo "WARN: --migrate-global-kb is set — the ENTIRE org KB below would be" >&2
        echo "      written to the target, overwriting any existing KB there." >&2
        KB_LIST_JSON="$WORKDIR/kb-documents.json"
        if ! curl_get_json "$SOURCE_URL$API_PREFIX/knowledge/documents" "$SOURCE_AUTH_FILE" >"$KB_LIST_JSON"; then
            echo "ERROR: failed to list KB documents. Server response:" >&2
            print_response_body "$KB_LIST_JSON"
            exit 1
        fi
        if ! jq empty "$KB_LIST_JSON" 2>/dev/null; then
            echo "ERROR: KB documents response is not valid JSON. Body:" >&2
            cat "$KB_LIST_JSON" >&2
            exit 1
        fi
        dry_doc_count="$(jq 'length' "$KB_LIST_JSON")"
        echo "KB documents   ${dry_doc_count} would be migrated" >&2
        while IFS=$'\t' read -r doc_key src_cred_id; do
            [[ -z "$doc_key" ]] && continue
            if [[ -n "$src_cred_id" && "$src_cred_id" != "null" ]]; then
                cred_name="$(src_credential_name_for_id "$src_cred_id")"
                if [[ -z "$cred_name" ]]; then
                    echo "               - $doc_key  [cred id=$src_cred_id has no name on source → default]" >&2
                else
                    tgt_cred_id="$(target_credential_id_for_name "$cred_name")"
                    if [[ -z "$tgt_cred_id" ]]; then
                        echo "               - $doc_key  [cred \"$cred_name\" missing on target → default]" >&2
                    else
                        echo "               - $doc_key  [cred \"$cred_name\" mapped]" >&2
                    fi
                fi
            else
                echo "               - $doc_key  [no cred → target default]" >&2
            fi
        done < <(jq -r '.[] | [.key, (.embeddingsCredentialsId // "")] | @tsv' "$KB_LIST_JSON")

        KB_ITEMS_CSV="$WORKDIR/kb-items.csv"
        if ! curl_get_to_file "$SOURCE_URL$API_PREFIX/knowledge/items/export" "$SOURCE_AUTH_FILE" "$KB_ITEMS_CSV"; then
            echo "ERROR: failed to export KB items. Server response:" >&2
            print_response_body "$KB_ITEMS_CSV"
            exit 1
        fi
        dry_item_count=$(( $(wc -l <"$KB_ITEMS_CSV") - 1 ))
        (( dry_item_count < 0 )) && dry_item_count=0
        echo "KB items       ${dry_item_count} items" >&2
        if (( dry_item_count > 10000 )); then
            echo "               WARN: exceeds the server's 10 000-row import cap — see README for manual chunking." >&2
        fi
        SUMMARY_KB_DOCS_STATE="dry_run"
        SUMMARY_KB_ITEMS_STATE="dry_run"
    else
        echo "KB             skipped — pass --migrate-global-kb to include the org KB" >&2
        SUMMARY_KB_DOCS_STATE="skipped"
        SUMMARY_KB_ITEMS_STATE="skipped"
    fi

    SUMMARY_PROJECT_STATE="dry_run"
    SUMMARY_PROJECT_NAME="$dry_project_name"
    exit 0
fi

# --- stage 2: project export/import ---------------------------------------

show_header "Migrating project $PROJECT_UUID -> $TARGET_PROJECT_UUID"

PROJECT_ZIP="$WORKDIR/project.zip"
EXPORT_URL="$SOURCE_URL$API_PREFIX/projects/$PROJECT_UUID/export?includeNluModels=$INCLUDE_NLU_MODELS"
IMPORT_URL="$TARGET_URL$API_PREFIX/projects/$TARGET_PROJECT_UUID/import?publishFlows=$PUBLISH_FLOWS"

echo "Exporting from $SOURCE_URL ..." >&2
if ! curl_get_to_file "$EXPORT_URL" "$SOURCE_AUTH_FILE" "$PROJECT_ZIP"; then
    echo "ERROR: project export failed. Server response:" >&2
    print_response_body "$PROJECT_ZIP"
    SUMMARY_PROJECT_STATE="failed"
    exit 1
fi

echo "Importing into $TARGET_URL ..." >&2
IMPORT_RESPONSE_FILE="$WORKDIR/import-response.json"
if ! curl_post_file "$IMPORT_URL" "$TARGET_AUTH_FILE" "$PROJECT_ZIP" >"$IMPORT_RESPONSE_FILE"; then
    echo "ERROR: project import failed. Server response:" >&2
    print_response_body "$IMPORT_RESPONSE_FILE"
    SUMMARY_PROJECT_STATE="failed"
    exit 1
fi
print_response_body "$IMPORT_RESPONSE_FILE"
SUMMARY_PROJECT_STATE="ok"
SUMMARY_PROJECT_NAME="$(jq -r '.projectDetails.name // empty' "$IMPORT_RESPONSE_FILE" 2>/dev/null || true)"

if [[ "$MIGRATE_GLOBAL_KB" != "true" ]]; then
    show_header "Skipping knowledge base" \
        "Project migrated. The org-wide KB was NOT touched." \
        "Pass --migrate-global-kb to also migrate KB documents + items."
    SUMMARY_KB_DOCS_STATE="skipped"
    SUMMARY_KB_ITEMS_STATE="skipped"
    exit 0
fi

# --- stage 3: KB documents ------------------------------------------------

show_header "Migrating knowledge base documents" \
    "WARNING: writing the ENTIRE org KB to $TARGET_URL"

KB_LIST_JSON="$WORKDIR/kb-documents.json"
if ! curl_get_json "$SOURCE_URL$API_PREFIX/knowledge/documents" "$SOURCE_AUTH_FILE" >"$KB_LIST_JSON"; then
    echo "ERROR: failed to list KB documents. Server response:" >&2
    print_response_body "$KB_LIST_JSON"
    exit 1
fi
if ! jq empty "$KB_LIST_JSON" 2>/dev/null; then
    echo "ERROR: KB documents response is not valid JSON. Body:" >&2
    cat "$KB_LIST_JSON" >&2
    exit 1
fi

mkdir -p "$WORKDIR/kb"

while IFS=$'\t' read -r doc_key source_cred_id; do
    [[ -z "$doc_key" ]] && continue
    echo "---" >&2
    echo "Document: $doc_key" >&2

    target_cred_id=""
    if [[ -n "$source_cred_id" && "$source_cred_id" != "null" ]]; then
        cred_name="$(src_credential_name_for_id "$source_cred_id")"
        if [[ -n "$cred_name" ]]; then
            target_cred_id="$(target_credential_id_for_name "$cred_name")"
            if [[ -z "$target_cred_id" ]]; then
                echo "WARN: credential \"$cred_name\" not found on target — falling back to default" >&2
            fi
        else
            echo "WARN: source credential id \"$source_cred_id\" has no name lookup — falling back to default" >&2
        fi
    fi

    if [[ -n "$target_cred_id" ]]; then
        params_json="$(jq -nc --arg id "$target_cred_id" '{embeddingsCredentialsId: $id}')"
    else
        params_json="{}"
    fi

    encoded_key="$(url_encode "$doc_key")"
    safe_name="$(slugify_filename "$doc_key")"
    xlsx_path="$WORKDIR/kb/${safe_name}.xlsx"
    put_response_file="$WORKDIR/kb/${safe_name}.upsert-response.json"

    if ! curl_get_to_file \
        "$SOURCE_URL$API_PREFIX/knowledge/documents/${encoded_key}/export/xlsx" \
        "$SOURCE_AUTH_FILE" \
        "$xlsx_path"; then
        echo "ERROR: failed to export document \"$doc_key\" from source. Server response:" >&2
        print_response_body "$xlsx_path"
        SUMMARY_KB_DOCS_FAILED+=("$doc_key")
        if [[ "$FAIL_FAST" == "true" ]]; then
            echo "Aborting after first failure (--fail-fast)" >&2
            break
        fi
        continue
    fi

    if ! curl_put_doc \
        "$TARGET_URL$API_PREFIX/knowledge/documents/${encoded_key}" \
        "$TARGET_AUTH_FILE" \
        "$xlsx_path" \
        "$params_json" >"$put_response_file"; then
        echo "ERROR: failed to upsert document \"$doc_key\" on target. Server response:" >&2
        print_response_body "$put_response_file"
        SUMMARY_KB_DOCS_FAILED+=("$doc_key")
        if [[ "$FAIL_FAST" == "true" ]]; then
            echo "Aborting after first failure (--fail-fast)" >&2
            break
        fi
        continue
    fi

    SUMMARY_KB_DOCS_OK+=("$doc_key")
    echo "OK: $doc_key" >&2
done < <(jq -r '.[] | [.key, (.embeddingsCredentialsId // "")] | @tsv' "$KB_LIST_JSON")
SUMMARY_KB_DOCS_STATE="completed"

# --- stage 4: KB items ----------------------------------------------------

show_header "Migrating knowledge base items"

KB_ITEMS_CSV="$WORKDIR/kb-items.csv"
if ! curl_get_to_file "$SOURCE_URL$API_PREFIX/knowledge/items/export" "$SOURCE_AUTH_FILE" "$KB_ITEMS_CSV"; then
    echo "ERROR: failed to export KB items from source. Server response:" >&2
    print_response_body "$KB_ITEMS_CSV"
    SUMMARY_KB_ITEMS_STATE="failed"
    exit 1
fi

ITEMS_RESPONSE_FILE="$WORKDIR/kb-items-response.json"
if ! curl_post_file \
    "$TARGET_URL$API_PREFIX/knowledge/items/import" \
    "$TARGET_AUTH_FILE" \
    "$KB_ITEMS_CSV" >"$ITEMS_RESPONSE_FILE"; then
    echo "ERROR: KB items import failed. Server response:" >&2
    print_response_body "$ITEMS_RESPONSE_FILE"
    echo >&2
    echo "If the error is 'FileTooBig', the server caps imports at 10 000 rows." >&2
    echo "Re-run with --workdir DIR to keep the exported CSV, then chunk and import" >&2
    echo "manually. See 'Manual chunking for KBs over 10 000 items' in tools/README.md." >&2
    SUMMARY_KB_ITEMS_STATE="failed"
    exit 1
fi
print_response_body "$ITEMS_RESPONSE_FILE"
SUMMARY_KB_ITEMS_STATE="ok"
SUMMARY_KB_ITEMS_ADDED="$(jq -r '.added // 0' "$ITEMS_RESPONSE_FILE" 2>/dev/null || echo 0)"
SUMMARY_KB_ITEMS_UPDATED="$(jq -r '.updated // 0' "$ITEMS_RESPONSE_FILE" 2>/dev/null || echo 0)"
SUMMARY_KB_ITEMS_UNCHANGED="$(jq -r '.unchanged // 0' "$ITEMS_RESPONSE_FILE" 2>/dev/null || echo 0)"

# --- exit code ------------------------------------------------------------

if (( ${#SUMMARY_KB_DOCS_FAILED[@]} > 0 )); then
    exit 1
fi


Before you start

You will need:

  • The project UUID. Open the project in the admin panel — the UUID is in the browser address bar, e.g. https://app.example.com/project/5a54ca09-1c3e-4f9a-bb21-7d0e2f6c8a10/....
  • An API key for the source environment (the X-Api-Key).
  • An API key for the target environment. For a same-environment copy you only need one key (see Copy within one environment).
  • A machine with bash, curl, and jq — or Docker, which gives you a ready-made runtime. See Running with Docker.

API keys are organization-scoped. The source key must belong to the organization that owns the project. The target key must belong to the organization you want the project to land in.


Your first migration

The most common case: copy a project from one environment to another.

.migrate-project-between-envs.sh 5a54ca09-1c3e-4f9a-bb21-7d0e2f6c8a10 \
  --url https://staging.example.com  --api-key at-SOURCE_KEY \
  --target-url https://prod.example.com --target-api-key at-TARGET_KEY

What happens:

  1. The script reads the project from staging.example.com.
  2. It writes the project into prod.example.com under the same UUID.
  3. It prints a summary showing what was migrated and how long it took.

By default only the project is migrated — flows, intents, settings, and configuration. The knowledge base is not touched (see Migrating the knowledge base).

A successful run looks like this:

##########################################################
Migrating project 5a54ca09-...  ->  5a54ca09-...
##########################################################

Exporting from https://staging.example.com ...
Importing into https://prod.example.com ...
{ "projectDetails": { "name": "My Bot", ... }, ... }

##########################################################
Skipping knowledge base

Project migrated. The org-wide KB was NOT touched.
Pass --migrate-global-kb to also migrate KB documents + items.
##########################################################

##########################################################
Summary
##########################################################

Project       5a54ca09-...  ->  5a54ca09-...   OK ("My Bot")
KB documents  skipped (global KB migration not requested; pass --migrate-global-kb)
KB items      skipped (global KB migration not requested; pass --migrate-global-kb)
Workdir       /tmp/migrate-between-envs-abc123 (auto-cleaned)
Elapsed       8s

##########################################################

Preview without making changes (--dry-run)

Before writing anything to the target, run with --dry-run. The script probes both environments and prints exactly what would be migrated — but makes no changes to the target.

.migrate-project-between-envs.sh 5a54ca09-1c3e-4f9a-bb21-7d0e2f6c8a10 \
  --url https://staging.example.com  --api-key at-SOURCE_KEY \
  --target-url https://prod.example.com --target-api-key at-TARGET_KEY \
  --dry-run

Example output:

Project        5a54ca09-...  ->  5a54ca09-...
               "My Bot"  (12 flows, 3 NLUs)
KB             skipped — pass --migrate-global-kb to include the org KB

This is the safest way to confirm you have the right project, the right target UUID, and the right credentials before committing.


Copy within one environment

To clone a project inside the same environment, omit the --target-* options — they fall back to the source URL and key.

.migrate-project-between-envs.sh 5a54ca09-1c3e-4f9a-bb21-7d0e2f6c8a10 \
  --url https://app.example.com --api-key at-MY_KEY \
  --target-project-id 9f3b7c21-44de-4a18-9c02-1e5d6b8f0a33

Use --target-project-id to give the copy a new UUID so it doesn't collide with the original.


Handling a UUID that already exists on the target

The project keeps its UUID by default. If that UUID is already used on the target — typically because another organization already has a project with it — the import fails. Assign a fresh UUID with --target-project-id:

.migrate-project-between-envs.sh 5a54ca09-1c3e-4f9a-bb21-7d0e2f6c8a10 \
  --url https://staging.example.com  --api-key at-SOURCE_KEY \
  --target-url https://prod.example.com --target-api-key at-TARGET_KEY \
  --target-project-id 9f3b7c21-44de-4a18-9c02-1e5d6b8f0a33

See Troubleshooting for the exact error this resolves.


Optional project settings

OptionWhat it does
--include-nlu-modelsAlso copies the trained binary NLU models. Without it, the target keeps NLU definitions but must retrain. Use it when you want the target to be immediately usable without retraining.
--publish-flowsPublishes all flows on the target right after import, so the bot is live without a manual publish step.
./tools/migrate-project-between-envs.sh 5a54ca09-1c3e-4f9a-bb21-7d0e2f6c8a10 \
  --url https://staging.example.com  --api-key at-SOURCE_KEY \
  --target-url https://prod.example.com --target-api-key at-TARGET_KEY \
  --include-nlu-models --publish-flows

Migrating the knowledge base

⚠️

The knowledge base is organization-wide, not per-project.

Migrating it copies every KB document and item in the source organization and overwrites the knowledge base in the target organization. This is hard to roll back. Never run it against a shared target that holds another tenant's knowledge base.

Because of that blast radius, KB migration is off by default. Opt in explicitly with --migrate-global-kb:

.migrate-project-between-envs.sh 5a54ca09-1c3e-4f9a-bb21-7d0e2f6c8a10 \
  --url https://staging.example.com  --api-key at-SOURCE_KEY \
  --target-url https://prod.example.com --target-api-key at-TARGET_KEY \
  --migrate-global-kb

We recommend pairing it with --dry-run first to see exactly which documents and how many items would be written:

.migrate-project-between-envs.sh 5a54ca09-1c3e-4f9a-bb21-7d0e2f6c8a10 \
  --url https://staging.example.com  --api-key at-SOURCE_KEY \
  --target-url https://prod.example.com --target-api-key at-TARGET_KEY \
  --migrate-global-kb --dry-run

Related options

OptionWhat it does
--migrate-global-kbRequired to migrate the KB at all. Off by default.
--fail-fastStop at the first document that fails to migrate, instead of continuing through the rest. Only relevant alongside --migrate-global-kb.

AI credentials

KB documents reference embedding credentials. The script matches credentials by display name: recreate them on the target with the same names before running. If a name is missing on the target, that document falls back to the target's default credential and the script logs a WARN.

Knowledge bases over 10 000 items

The server limits each item import to 10 000 rows. If your KB is larger, the import fails with FileTooBig. The script does not auto-split (line-based splitting can corrupt rows that contain quoted newlines). To handle a large KB, re-run with --workdir to keep the exported CSV, then split and import the chunks manually — the operator README has step-by-step commands.


Running with Docker

If you don't want to install bash/curl/jq locally, use the following Dockerfile for a consistent runtime:

FROM debian:bookworm-slim

RUN apt-get update \
    && apt-get install -y --no-install-recommends \
        bash \
        curl \
        jq \
        file \
        ca-certificates \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /work
COPY migrate-project-between-envs.sh /usr/local/bin/migrate-project-between-envs.sh
RUN chmod +x /usr/local/bin/migrate-project-between-envs.sh

ENTRYPOINT ["migrate-project-between-envs.sh"]

Build the image once:

docker build -t automate-migrate .

Then run it with the same flags as the script (the script is the image's entry point):

docker run --rm automate-migrate 5a54ca09-1c3e-4f9a-bb21-7d0e2f6c8a10 \
  --url https://staging.example.com  --api-key at-SOURCE_KEY \
  --target-url https://prod.example.com --target-api-key at-TARGET_KEY

To keep staged files (for example, the exported CSV for manual KB chunking), mount a local directory and point --workdir at it:

docker run --rm -v "$PWD/migrate-data:/work" automate-migrate \
  5a54ca09-1c3e-4f9a-bb21-7d0e2f6c8a10 \
  --url https://staging.example.com  --api-key at-SOURCE_KEY \
  --target-url https://prod.example.com --target-api-key at-TARGET_KEY \
  --workdir /work

Reading the summary

Every run ends with a summary block (printed even on failure):

Project       5a54ca09-...  ->  5a54ca09-...   OK ("My Bot")
KB documents  2 total, 2 OK, 0 failed
KB items      added 5, updated 3, unchanged 142
Workdir       /tmp/migrate-between-envs-abc123 (auto-cleaned)
Elapsed       8s
  • Project — source UUID → target UUID, and the result.
  • KB documents / KB items — counts when migrated, otherwise skipped.
  • Workdir — where files were staged (auto-cleaned unless you set --workdir).
  • Elapsed — total run time.

Exit codes

CodeMeaning
0Success.
1An operational failure — network, authentication, a server-side rejection, or a failed KB document. Check the summary and the last ERROR: line.
2Invalid command-line usage (e.g. a malformed UUID or a missing required option).

Troubleshooting

SymptomCauseWhat to do
project import failed404The UUID already exists on the target in a different organization.Use the API key of the organization that owns the UUID on the target, or pass --target-project-id <fresh-uuid>, or delete the existing project on the target.
FileTooBig: Maximum allowed: 10000The knowledge base exceeds the per-import row cap.See Knowledge bases over 10 000 items.
curl: (7) Failed to connect ...Wrong URL, DNS, or firewall.Verify the --url / --target-url values. Add --debug to see the exact requests.
Invalid <project-uuid> (exit 2)The UUID is mistyped.Re-copy it from the admin panel address bar.
The run hangsShould not happen — requests have built-in timeouts.Re-run with --debug and share the output with support.

Getting more detail

Add --debug to print every HTTP request to the terminal. API keys are masked in the output, but URLs are shown verbatim — review before pasting debug output into a ticket or chat.

./tools/migrate-project-between-envs.sh 5a54ca09-1c3e-4f9a-bb21-7d0e2f6c8a10 \
  --url https://staging.example.com  --api-key at-SOURCE_KEY \
  --target-url https://prod.example.com --target-api-key at-TARGET_KEY \
  --debug

Quick reference

./tools/migrate-project-between-envs.sh <project-uuid> \
  --url URL --api-key KEY \
  [--target-url URL] [--target-api-key KEY] [--target-project-id UUID] \
  [--include-nlu-models] [--publish-flows] \
  [--migrate-global-kb] [--fail-fast] \
  [--dry-run] [--workdir DIR] [--debug]
OptionDefaultEffect
--url URLrequiredSource environment base URL.
--api-key KEYrequiredSource API key (X-Api-Key).
--target-url URL--urlTarget environment base URL.
--target-api-key KEY--api-keyTarget API key.
--target-project-id UUIDsource UUIDUUID for the project on the target.
--include-nlu-modelsoffInclude trained binary NLU models.
--publish-flowsoffPublish all flows on the target after import.
--migrate-global-kboffAlso migrate the org-wide knowledge base. ⚠️ Overwrites the target org's entire KB.
--fail-fastoffStop at the first failed KB document.
--dry-runoffPreview only; no writes to the target.
--workdir DIRtemp dirWhere to stage downloaded files.
--debugoffPrint every HTTP request (keys masked, URLs shown).