generate_library_messages.py
The generate_library_messages.py script generates librarymessage.v0 resource files for a Qualtrics XM Solution plugin from messages that already exist in a Qualtrics library, and wires each one up as a Create step in the plugin's solutionExecution.json.
Use this script when you already have working messages sitting in a Qualtrics library and want to bring them into a solution source as resource files, instead of hand-writing each JSON file and step.
If you would like to join the early access program for XM Solution Plugins, then please let us know by taking this survey.
This feature is in Early Access and is not yet generally available. Functionality, APIs, and documentation may change before general availability.
What it does
For every message ID listed in the config:
- Calls
GET https://{dc}.qualtrics.com/API/v3/libraries/{library}/messages/{messageId}. - Reads the message's
description(used as the resource and step name) andcategory(mapped to the correctmessageTypeshort code). - Writes one
Resources_*/<Name>.jsonresource file shaped forlibrarymessage.v0. - Adds (or updates) a matching
Createstep insolutionExecution.json.
The script auto-detects the Resources_* folder and solutionExecution*.json file under <plugin-dir>/src/. Both names change every time the solution is rebuilt or resynced, so the script always looks for whichever one currently exists. If it finds more than one match for either, it exits with an error listing them.
Prerequisites
- Python 3 (no third-party packages required)
- A Qualtrics API token with read access to the target library
The script
Save the following as generate_library_messages.py and run it from anywhere on your machine:
#!/usr/bin/env python3
"""Generate librarymessage.v0 resource files from existing Qualtrics library messages.
Reads a config JSON shaped like:
{ "token": "", "dc": "b1-prv", "library": "UR_d7lcm9K1bkEqz1I", "messages": ["MS_xxx", "MS_yyy"] }
For every message id, fetches
GET https://{dc}.qualtrics.com/API/v3/libraries/{library}/messages/{messageId}
and writes ONE new Resources_*/<Name>.json resource file plus ONE matching
"Create" step in solutionExecution.json. The file/step name is derived by
slugifying the message's own "description" field; messageType is mapped from
the message's "category" field via CATEGORY_TO_MESSAGE_TYPE.
Usage:
python3 generate_library_messages.py --config messages_config.json
python3 generate_library_messages.py --config messages_config.json --plugin-dir ../xflow-plugin
If "token" is left blank in the config, the QUALTRICS_API_TOKEN env var is used,
falling back to an interactive prompt. Keep real tokens out of the config file
when possible -- it's easy to accidentally commit a secret sitting in JSON.
"""
import argparse
import getpass
import glob
import json
import os
import re
import sys
import urllib.error
import urllib.request
LIBRARY_ID_PLACEHOLDER = "{{ $.user.userId}}"
TRANSFORMATION_FILE = "interpolate.js"
# librarymessage.v0 only accepts these short codes for messageType. The GET
# /libraries/{id}/messages/{id} endpoint's "category" field comes back as a
# free-text word (e.g. "invite", "smsInvite") instead of the short code the
# create endpoint actually requires (e.g. "MIE", "MSM") -- passing "category"
# straight through produces an invalid messageType and a 400 on creation.
CATEGORY_TO_MESSAGE_TYPE = {
"mie": "MIE",
"mis": "MIS",
"mre": "MRE",
"mte": "MTE",
"mtm": "MTM",
"mge": "MGE",
"mvd": "MVD",
"mlf": "MLF",
"mes": "MES",
"msm": "MSM",
"mtk": "MTK",
"msr": "MSR",
"mst": "MST",
"invite": "MIE",
"inviteemails": "MIE",
"inactivesurvey": "MIS",
"inactivesurveymessages": "MIS",
"reminder": "MRE",
"reminderemails": "MRE",
"thankyou": "MTE",
"thankyouemails": "MTE",
"endofsurvey": "MTM",
"endofsurveymessages": "MTM",
"general": "MGE",
"generalmessages": "MGE",
"validation": "MVD",
"validationmessages": "MVD",
"lookandfeel": "MLF",
"lookandfeelmessages": "MLF",
"subject": "MES",
"emailsubject": "MES",
"smsinvite": "MSM",
"ticket": "MTK",
"ticketmessages": "MTK",
"smsreminder": "MSR",
"smsthankyou": "MST",
}
def resolve_message_type(category):
normalized = re.sub(r"[^A-Za-z]", "", category or "").lower()
return CATEGORY_TO_MESSAGE_TYPE.get(normalized)
def find_single(src_dir, pattern, kind, only_dirs=False):
matches = sorted(
p for p in glob.glob(os.path.join(src_dir, pattern))
if (os.path.isdir(p) if only_dirs else os.path.isfile(p))
)
if not matches:
sys.exit(f"No {kind} matching {src_dir}/{pattern} found.")
if len(matches) > 1:
sep = ", "
sys.exit(f"Found multiple {kind} matching {src_dir}/{pattern}: {sep.join(matches)}")
return matches[0]
def fetch_message(dc, library_id, message_id, token):
url = f"https://{dc}.qualtrics.com/API/v3/libraries/{library_id}/messages/{message_id}"
request = urllib.request.Request(url, headers={"X-API-TOKEN": token, "Accept": "application/json"})
try:
with urllib.request.urlopen(request) as response:
return json.load(response)
except urllib.error.HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
raise RuntimeError(f"{message_id}: HTTP {error.code} - {body}") from error
except urllib.error.URLError as error:
raise RuntimeError(f"{message_id}: connection failed - {error.reason}") from error
def slugify(description, fallback):
slug = re.sub(r"[^A-Za-z0-9]", "", description or "")
return slug or fallback
def dedupe_step_id(step_id, used_ids):
used_lower = {u.lower() for u in used_ids}
if step_id.lower() not in used_lower:
return step_id
suffix = 1
candidate = f"{step_id}_{suffix}"
while candidate.lower() in used_lower:
suffix += 1
candidate = f"{step_id}_{suffix}"
return candidate
def build_resource(description, message_type, translations):
payload = {lang.upper(): text for lang, text in translations.items()}
return {
"elementName": description,
"messageType": message_type,
"libraryID": LIBRARY_ID_PLACEHOLDER,
"payload": payload,
}
def upsert_step(steps, step_id, resources_dir_name):
template_path = f"{resources_dir_name}/{step_id}.json"
transformation_path = f"{resources_dir_name}/{TRANSFORMATION_FILE}"
for step in steps:
if step.get("id") == step_id:
step["type"] = "Create"
step["config"] = {
"apiVersion": "librarymessage.v0",
"templateFilePath": template_path,
"transformationFilePath": transformation_path,
}
return "updated"
steps.append({
"id": step_id,
"type": "Create",
"config": {
"apiVersion": "librarymessage.v0",
"templateFilePath": template_path,
"transformationFilePath": transformation_path,
},
})
return "added"
def resolve_token(config_token, library):
if config_token:
return config_token
env_token = os.environ.get("QUALTRICS_API_TOKEN")
if env_token:
return env_token
return getpass.getpass(f"X-API-TOKEN for library {library}: ")
def main():
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--config", required=True,
help="Path to the {token, dc, library, messages[]} config JSON")
parser.add_argument("--plugin-dir", default="xflow-plugin",
help="Path to the solution plugin folder (the one containing src/); default: xflow-plugin")
args = parser.parse_args()
src_dir = os.path.join(args.plugin_dir, "src")
resources_dir_fs = find_single(src_dir, "Resources_*", "resources directories", only_dirs=True)
resources_dir_name = os.path.basename(resources_dir_fs)
solution_execution_path = find_single(src_dir, "solutionExecution*.json", "solutionExecution files")
with open(args.config, encoding="utf-8") as f:
config = json.load(f)
with open(solution_execution_path, encoding="utf-8") as f:
solution_execution = json.load(f)
steps = solution_execution["steps"]
used_ids = {step["id"] for step in steps}
failures = []
written = 0
dc = config["dc"]
library = config["library"]
token = resolve_token(config.get("token"), library)
if not token:
sys.exit(f"An X-API-TOKEN is required for library {library}.")
for message_id in config["messages"]:
try:
response = fetch_message(dc, library, message_id, token)
except RuntimeError as error:
print(f"FAILED {error}", file=sys.stderr)
failures.append(message_id)
continue
result = response.get("result", {})
description = result.get("description", "")
category = result.get("category", "")
translations = result.get("messages") or result.get("payload") or {}
message_type = resolve_message_type(category)
if message_type is None:
print(
f"FAILED {message_id}: category '{category}' has no known messageType mapping "
"-- add it to CATEGORY_TO_MESSAGE_TYPE",
file=sys.stderr,
)
failures.append(message_id)
continue
step_id = slugify(description, message_id)
deduped = dedupe_step_id(step_id, used_ids)
if deduped != step_id:
print(
f"NOTE {message_id}: name '{step_id}' already exists, using '{deduped}' instead",
file=sys.stderr,
)
step_id = deduped
used_ids.add(step_id)
resource = build_resource(description, message_type, translations)
resource_path = os.path.join(resources_dir_fs, f"{step_id}.json")
action = upsert_step(steps, step_id, resources_dir_name)
os.makedirs(os.path.dirname(resource_path), exist_ok=True)
with open(resource_path, "w", encoding="utf-8") as f:
json.dump(resource, f, indent=4, ensure_ascii=False)
f.write("\n")
written += 1
print(f"wrote {resource_path} ({len(translations)} languages), {action} step '{step_id}'")
if written:
with open(solution_execution_path, "w", encoding="utf-8") as f:
json.dump(solution_execution, f, indent=4, ensure_ascii=False)
f.write("\n")
print(f"updated {solution_execution_path}")
if failures:
sep = ", "
print(f"\n{len(failures)} message(s) failed: {sep.join(failures)}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
Config format
Create a JSON config file (for example, messages_config.json) with the following fields:
{
"token": "",
"dc": "b1-prv",
"library": "UR_d7lcm9K1bkEqz1I",
"messages": ["MS_xxx", "MS_yyy", "MS_zzz"]
}
| Field | Required | Notes |
|---|---|---|
token | No | X-API-TOKEN for the library. Leave blank to use the QUALTRICS_API_TOKEN environment variable, or you will be prompted interactively. Keep real tokens out of committed config files. |
dc | Yes | Data center, for example b1-prv or iad1. |
library | Yes | Library ID the messages live in (UR_...). |
messages | Yes | List of message IDs (MS_...) to pull from that library. |
Usage
python3 generate_library_messages.py --config messages_config.json --plugin-dir /path/to/xflow-plugin
--plugin-dir defaults to xflow-plugin relative to the current directory. Pass the full path if you are not running from inside the folder that contains it.
After the script finishes, run the normal build-submit-validate workflow to incorporate the new resources:
solution build
xmt submit extension
solution validate
Naming and collision handling
The resource and step name is derived by slugifying the message's description field (stripping anything that is not a letter or digit). If two messages slugify to the same name, the second is written with a _1, _2, ... suffix rather than being skipped.
This comparison is case-insensitive because resource files land on case-insensitive file systems by default (macOS, Windows). "VicMail" and "Vicmail" resolve to the same file path, so the script treats them as equivalent and assigns a suffix instead of silently overwriting one.
messageType mapping
librarymessage.v0 only accepts these short codes for messageType. The API's category field returns a free-text word such as "invite" or "smsInvite", not these codes — passing the raw category value produces a 400 Bad Request. The script maps the fetched category to the correct code via CATEGORY_TO_MESSAGE_TYPE before writing the resource file. If a category is not in the table, the script fails loudly for that message instead of guessing.
| Code | Full name |
|---|---|
MIE | InviteEmails |
MIS | InactiveSurveyMessages |
MRE | ReminderEmails |
MTE | ThankYouEmails |
MTM | EndOfSurveyMessages |
MGE | GeneralMessages |
MVD | ValidationMessages |
MLF | LookAndFeelMessages |
MES | EmailSubject |
MSM | SMSInvite |
MTK | TicketMessages |
MSR | SMSReminder |
MST | SMSThankYou |
How resource creation works
A generated resource file looks like:
{
"elementName": "New Message",
"messageType": "MIE",
"libraryID": "{{ $.user.userId}}",
"payload": {
"EN": "<strong>Content of Message</strong>"
}
}
elementName, messageType, and payload are required by the librarymessage.v0 resource type. The libraryID field uses {{ $.user.userId}} so the platform injects the correct library for the user who runs the create step at runtime.
Each resource file is paired with a Create step in solutionExecution.json:
{
"id": "SomeStepId",
"type": "Create",
"config": {
"apiVersion": "librarymessage.v0",
"templateFilePath": "Resources_xxxx/SomeStepId.json",
"transformationFilePath": "Resources_xxxx/interpolate.js"
}
}
The transformationFilePath points to the standard interpolate.js boilerplate already present in your Resources folder. At build and submit time, the platform runs the resource through interpolate(), which replaces {{ $.path.to.value }} tokens with values from the context object. See the JavaScript Library for details.
The platform's documented default context shape is:
{
setup: { answers: {} },
user: { userID: string, brandID: string },
system: { language: string }
}
So "libraryID": "{{ $.user.userId}}" resolves to the library that belongs to the user running the create step. No plugin code sets this value — the platform injects it.
Troubleshooting
Empty libraryID after creation
If a created message has an empty libraryID, the {{ $.user.userId}} token resolved to an empty string. This is a known issue in some dev or test brand environments where the platform context is missing the user key entirely — not something fixable in the resource file. Check the debug log Context block for a missing user key before assuming the file is wrong.
Script exits with multiple Resources directories found
The script cannot safely guess which Resources_* folder to use. Remove or rename the extra directory before running the script again.
Unmapped category
If the script reports that a category has no known messageType mapping, open the script and add the new entry to the CATEGORY_TO_MESSAGE_TYPE dictionary at the top, then re-run.