Skill 12 · Exo Content Bindings
Subchapter 12.1
references/api-workflow.mdMarkdown32 KBView on GitHub
Full CMA call sequences for the Process steps referenced from SKILL.md. Examples use curl. If no shell/exec tool is available, make the equivalent calls through the contentful-mcp MCP tools instead — same endpoints, same payloads. If neither is available, tell the user you cannot reach the Contentful API and stop rather than guessing at space state.
| Input | Source | Required | Default |
|---|---|---|---|
| Space ID | User provides or detect from context | Yes | — |
| Environment ID | User provides | No | master |
| CMA token | User provides or $CMA_TOKEN env var | Yes | — |
API base URL ($API_BASE_URL) | Full base URL for CMA requests. Must start with https:// — a bare hostname makes curl fall back to plain HTTP and can send the token unencrypted. | No | https://api.contentful.com (https://api.flinkly.com for staging) |
For a reference space on a different host, set $REF_API_BASE_URL the same way (with https://).
Fetch all component types and content types.
# Fetch component types
curl -s -H "Authorization: Bearer $CMA_TOKEN" \
"$API_BASE_URL/spaces/$SPACE_ID/environments/$ENVIRONMENT_ID/component_types"# Fetch content types (source data)
curl -s -H "Authorization: Bearer $CMA_TOKEN" \
"$API_BASE_URL/spaces/$SPACE_ID/environments/$ENVIRONMENT_ID/content_types"Ask: “Is there a reference space with the same components already wired with DAs? If yes, provide its space ID.”
If a reference exists, fetch its DAs FIRST and replicate their patterns exactly. This eliminates guesswork around query style, pointer syntax, RichText handling, and parameter naming.
# Fetch DAs from reference space
curl -s -H "Authorization: Bearer $CMA_TOKEN" \
"$REF_API_BASE_URL/spaces/$REF_SPACE_ID/environments/$REF_ENVIRONMENT_ID/data_assemblies" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for da in data.get('items', []):
print(f\"\n== {da['name']} (id: {da['sys']['id']}) ==\")
print(f\" dataType: {json.dumps(da['sys'].get('dataType', []), indent=2)}\")
print(f\" parameters: {json.dumps(da.get('parameters', {}), indent=2)}\")
print(f\" resolvers: {json.dumps(da.get('resolvers', {}), indent=2)}\")
print(f\" return: {json.dumps(da.get('return', {}), indent=2)}\")"What to extract from the reference:
_node vs. direct (contentType(id: $id))$from vs. bare string pointers{ document: json } alias + bare parent pointer{contentTypeId}Idkind is used in resolversIf no reference space exists, proceed — but flag higher risk of style drift in the binding plan.
This is the primary decision fork. Every component type falls into one of two categories, and the DA design approach differs fundamentally.
| Signal | Classification |
|---|---|
No componentTree or empty componentTree | Coded |
Contentful:CodedImplementation annotation | Coded |
Has componentTree with children | Composite |
Contentful:ComposedImplementation annotation | Composite |
# Classify all component types
curl -s -H "Authorization: Bearer $CMA_TOKEN" \
"$API_BASE_URL/spaces/$SPACE_ID/environments/$ENVIRONMENT_ID/component_types" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
coded, composite = [], []
for ct in data.get('items', []):
tree = ct.get('componentTree', {})
has_children = bool(tree.get('children', []))
cps = ct.get('contentProperties', [])
slots = ct.get('slots', [])
entry = {
'id': ct['sys']['id'],
'name': ct['name'],
'contentProps': len(cps),
'slots': len(slots)
}
if has_children:
composite.append(entry)
else:
coded.append(entry)
print('=== CODED (leaf components — straightforward DA design) ===')
for c in coded:
print(f\" {c['name']} (id: {c['id']}) — {c['contentProps']} content props, {c['slots']} slots\")
print(f\"\n=== COMPOSITE (tree components — check hoisting + slots) ===\")
for c in composite:
print(f\" {c['name']} (id: {c['id']}) — {c['contentProps']} content props, {c['slots']} slots\")"| Coded | Composite | |
|---|---|---|
| Content properties | All direct and public | Mix of direct + hoisted from children |
| Hoisting | N/A — no children | Must identify which props are hoisted vs. direct |
| Slots | May have slots (rare) | Usually has slots |
| DA complexity | Simple: one resolver maps fields → props | Moderate: must respect hoisting + slot boundaries |
| May not need a DA | Unlikely (it has content props for a reason) | Possible — if ALL content comes via slot children |
Coded components are straightforward. All contentProperties are public and directly bindable.
Steps:
contentProperties (all are public)# Inspect a coded component's content properties
curl -s -H "Authorization: Bearer $CMA_TOKEN" \
"$API_BASE_URL/spaces/$SPACE_ID/environments/$ENVIRONMENT_ID/component_types/$CT_ID" \
| python3 -c "
import sys, json
ct = json.load(sys.stdin)
print(f\"== {ct['name']} (CODED) ==\")
print(f\"\\nContent properties (ALL public, ALL bindable):\")
for cp in ct.get('contentProperties', []):
req = '✓ required' if cp.get('required') else '○ optional'
print(f\" {cp['id']}: {cp['type']} ({req})\")
slots = ct.get('slots', [])
if slots:
print(f\"\\nSlots (children need their own DAs):\")
for s in slots:
print(f\" {s['id']}: {s.get('name', '')}\")"Composites require more analysis. Their contentProperties array contains a mix of:
componentTreeBoth are public and bindable, but you need to understand the origin to design the GraphQL query correctly.
| Situation | Needs DA? |
|---|---|
Has contentProperties that should be filled from entries | Yes |
| All content comes from slot children (each with their own DA) | No — skip |
Only has designProperties (no content props) | No — skip |
Has contentProperties but all are manually filled (literals) | No — use manual values |
# Inspect a composite component — content properties + tree
curl -s -H "Authorization: Bearer $CMA_TOKEN" \
"$API_BASE_URL/spaces/$SPACE_ID/environments/$ENVIRONMENT_ID/component_types/$CT_ID" \
| python3 -c "
import sys, json
ct = json.load(sys.stdin)
print(f\"== {ct['name']} (COMPOSITE) ==\")
cps = ct.get('contentProperties', [])
print(f\"\\nPublic content properties ({len(cps)} total — all bindable):\")
for cp in cps:
req = '✓ required' if cp.get('required') else '○ optional'
print(f\" {cp['id']}: {cp['type']} ({req})\")
tree = ct.get('componentTree', {})
children = tree.get('children', [])
print(f\"\\nComponent tree ({len(children)} children):\")
for node in children:
props = node.get('props', {})
bindings = []
for key, val in props.items():
if isinstance(val, str) and '\$contentProperties/' in val:
bindings.append(f\"{key} ← {val}\")
ct_ref = node.get('type', node.get('componentType', '?'))
if bindings:
print(f\" {ct_ref}: {', '.join(bindings)}\")
else:
print(f\" {ct_ref}: (no content property bindings)\")
slots = ct.get('slots', [])
if slots:
print(f\"\\nSlots ({len(slots)} — children here get their OWN DAs):\")
for s in slots:
allowed = s.get('allowedTypes', 'any')
print(f\" {s['id']}: allows {allowed}\")"# Fetch existing data assemblies
curl -s -H "Authorization: Bearer $CMA_TOKEN" \
"$API_BASE_URL/spaces/$SPACE_ID/environments/$ENVIRONMENT_ID/data_assemblies" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for da in data.get('items', []):
returns = list(da.get('return', {}).keys())
params = list(da.get('parameters', {}).keys())
print(f\" {da['name']} (id: {da['sys']['id']})\")
print(f\" params: {params}, returns: {returns}\")"Do not skip this step. Field names are ambiguous — topic could be a category tag or a paragraph, subline could be a subtitle or body text. Fetch 1-2 sample entries per source content type to see what fields actually contain:
# Sample entries for a content type
curl -s -H "Authorization: Bearer $CMA_TOKEN" \
"$API_BASE_URL/spaces/$SPACE_ID/environments/$ENVIRONMENT_ID/entries?content_type=$CT_ID&limit=2" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for entry in data.get('items', []):
print(f\"\\n== {entry['sys']['id']} ==\")
for field_id, locales in entry.get('fields', {}).items():
val = next(iter(locales.values()), None)
if isinstance(val, str):
preview = val[:80] + ('...' if len(val) > 80 else '')
print(f\" {field_id}: \\\"{preview}\\\"\")
elif isinstance(val, dict) and val.get('sys', {}).get('type') == 'Link':
print(f\" {field_id}: → Link<{val['sys'].get('linkType', '?')}> {val['sys'].get('id', '')}\")
elif isinstance(val, dict) and 'nodeType' in val:
print(f\" {field_id}: [RichText document]\")
elif isinstance(val, list):
print(f\" {field_id}: [{len(val)} items]\")
else:
print(f\" {field_id}: {json.dumps(val)[:60]}\")"Use the actual field values to resolve ambiguities:
Symbol containing “AI & Automation” is a category tag, not a descriptionText containing a full paragraph is body content, not a subtitleRichText field contains structured JSON (nodeType, content arrays)Flag ambiguous mappings — if multiple source fields could map to one target property, note the ambiguity in the plan and confirm with the user.
Gate first, then match. For every candidate pair, in order:
contentProperties, stop — the golden rule applies. Record it as blocked-pending-hoisting, and check the required-but-unhoisted contradiction rule.Match content type fields to component content properties using these heuristics:
| Content property type | Matching content type field | Resolver kind |
|---|---|---|
String | Symbol, Text | entity |
RichText | RichText or String (JSON) | entity (see richtext-and-types.md) |
Media (url/width/height/alt) | Link<Asset> | entity |
Array of Record | Array<Link<Entry>> (multi-ref) | collection |
Record (structured) | Link<Entry> (single ref) | entity (nested DA or inline) |
TypeRef (whole-component) | Link<Entry> | Nested DA |
Naming conventions:
{component-type-id}-{source-content-type-id} (e.g., hero-banner-page-hero){contentTypeId}Id (e.g., capabilityId, testimonialId) — appears in the marketer-facing binding panelname field: Human-readable label (e.g., "name": "Capability") — always include thisChoosing the source when several fields or content types could fill one target:
allowedTypes must contain the content type. This is a hard filter, not a preference.Confidence maps to action: high = direct match, map it. medium = plausible, map it and mark it for review in the Binding Plan. low = weak. Prefer leaving a property unmapped and naming it over mapping something low-confidence and wrong — a wrong mapping is much harder for the user to notice than an absent one, because it produces plausible content in the right shape. Unmapped properties are a line item in the Binding Plan; wrong ones are a silent defect.
Never claim to have removed, replaced, or rewired an existing binding. Existing DAs found in step 5.1 are left alone unless the user explicitly asks for a change. If a component already has a DA covering the same properties, reuse or extend it (see worked-example.md, “Reusing One DA Across Multiple Component Types”) — do not create a competing one and do not describe the result as having replaced anything.
Present the FULL plan as a single table covering every component type. This is the contract the user approves before execution begins.
╔══════════════════════════════════════════════════════════════════════════════╗
║ BINDING PLAN ║
╠══════════════════════════════════════════════════════════════════════════════╣
1. HeroBanner (CODED)
DA: hero-banner-page-hero [NEW]
Source: pageHero | Parameter: hero
┌─────────────────┬────────────────────┬──────────┬──────────────┐
│ Content Property │ Source Field │ Type │ Depth │
├─────────────────┼────────────────────┼──────────┼──────────────┤
│ title (req) │ pageHero.headline │ String │ shallow │
│ subtitle │ pageHero.subline │ String │ shallow │
│ image (req) │ pageHero.bgImage │ Record │ shallow │
│ ctaLabel │ pageHero.ctaText │ String │ shallow │
│ ctaUrl │ pageHero.ctaLink │ String │ shallow │
└─────────────────┴────────────────────┴──────────┴──────────────┘
2. BookCard (CODED)
DA: book-card-book [NEW]
Source: book | Parameter: bookId
┌─────────────────┬────────────────────┬──────────┬──────────────┐
│ Content Property │ Source Field │ Type │ Depth │
├─────────────────┼────────────────────┼──────────┼──────────────┤
│ title (req) │ book.title │ String │ shallow │
│ authorName │ book.author.name │ String │ 2 hops (deep)│
└─────────────────┴────────────────────┴──────────┴──────────────┘
3. ProductSection (COMPOSITE)
Ask: “Here’s the binding plan. Does this look correct? Any mappings to add, remove, change source content types, or rename DA IDs?”
Do NOT proceed to step 6 until the user explicitly approves the plan.
If the user requests changes, update the plan and present it again. Only proceed once approved.
PUT body structure: The sys object in the request body MUST contain id, type, and dataType. Optionally include version for updates. All other sys fields (space, environment, createdBy, etc.) are server-managed.
{
"sys": {
"id": "my-da-id",
"type": "DataAssembly",
"dataType": [...]
},
"metadata": { "tags": [] },
"name": "...",
"description": "...",
"parameters": { ... },
"resolvers": { ... },
"return": { ... }
}Notes on optional fields:
kind ("entity" or "collection") is accepted but not required. If replicating from a reference space that omits it, don’t add it.name on parameters (e.g., "name": "Capability") is the human-readable label shown in the binding panel. Always include it.See resolver-patterns.md for full request bodies covering entity, collection, nested-DA, asset/Media, and deep-binding resolvers.
This step is required. After creating a DA, you must add it to the target component type’s dataAssemblies array. Without this link, the DA won’t appear in the editor’s binding panel for that component.
# Fetch the component type's current state
CT_RESPONSE=$(curl -s -H "Authorization: Bearer $CMA_TOKEN" \
"$API_BASE_URL/spaces/$SPACE_ID/environments/$ENVIRONMENT_ID/component_types/$CT_ID")
CT_VERSION=$(echo "$CT_RESPONSE" | python3 -c "import sys, json; print(json.load(sys.stdin)['sys']['version'])")
# Build the PUT body: append the DA link (if not already present) and drop sys.
# DA_ID is passed as an argument, not interpolated into Python source.
PUT_BODY=$(echo "$CT_RESPONSE" | python3 -c '
import sys, json
da_id = sys.argv[1]
ct = json.load(sys.stdin)
das = ct.get("dataAssemblies", [])
if not any(d.get("sys", {}).get("urn", "").endswith("/" + da_id) for d in das):
das.append({"sys": {
"type": "ResourceLink",
"linkType": "Contentful:DataAssembly",
"urn": "crn:contentful:::experience:spaces/$self/environments/$self/dataAssemblies/" + da_id,
}})
ct["dataAssemblies"] = das
del ct["sys"] # API rejects sys in the PUT body
print(json.dumps(ct))' "$DA_ID")
# Update the component type
curl -s -X PUT \
-H "Authorization: Bearer $CMA_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Contentful-Version: $CT_VERSION" \
"$API_BASE_URL/spaces/$SPACE_ID/environments/$ENVIRONMENT_ID/component_types/$CT_ID" \
-d "$PUT_BODY"URN format for DA links:
crn:contentful:::experience:spaces/$self/environments/$self/dataAssemblies/{da-id}# Fetch the current version
VERSION=$(curl -s -H "Authorization: Bearer $CMA_TOKEN" \
"$API_BASE_URL/spaces/$SPACE_ID/environments/$ENVIRONMENT_ID/data_assemblies/$DA_ID" \
| python3 -c "import sys, json; print(json.load(sys.stdin)['sys']['version'])")
# Publish
curl -s -X PUT \
-H "Authorization: Bearer $CMA_TOKEN" \
-H "X-Contentful-Version: $VERSION" \
"$API_BASE_URL/spaces/$SPACE_ID/environments/$ENVIRONMENT_ID/data_assemblies/$DA_ID/published"curl -s -H "Authorization: Bearer $CMA_TOKEN" \
"$API_BASE_URL/spaces/$SPACE_ID/environments/$ENVIRONMENT_ID/data_assemblies/$DA_ID" \
| python3 -c "
import sys, json
da = json.load(sys.stdin)
print(f\"✓ {da['name']} (v{da['sys']['version']}, published: {'publishedVersion' in da['sys']})\")
print(f\" Parameters: {list(da.get('parameters', {}).keys())}\")
print(f\" Resolvers: {list(da.get('resolvers', {}).keys())}\")
print(f\" Returns: {list(da.get('return', {}).keys())}\")"If a reference space was identified in step 1.5, compare your created DAs against it before producing the final binding map:
# Compare created DAs against reference
curl -s -H "Authorization: Bearer $CMA_TOKEN" \
"$REF_API_BASE_URL/spaces/$REF_SPACE_ID/environments/$REF_ENVIRONMENT_ID/data_assemblies" \
| python3 -c "
import sys, json
ref = json.load(sys.stdin)
for da in ref.get('items', []):
print(f\"REF: {da['sys']['id']}\")
dt = da['sys'].get('dataType', [])
for d in dt:
print(f\" {d['id']}: {d['type']}\")
ret = da.get('return', {})
for k, v in ret.items():
style = 'bare' if isinstance(v, str) else '\$from'
print(f\" return.{k}: {style}\")"Check for:
dataType alignment — especially RichText vs. String (most common drift)$from (should be consistent)If discrepancies are found, fix them before producing the binding map.
The PUT /data_assemblies/:id endpoint uses upsert semantics:
X-Contentful-Version header)409 Conflict: fetch current version, retry with updated header# Upsert pattern with conflict retry
RESPONSE=$(curl -s -w "\n%{http_code}" -X PUT \
-H "Authorization: Bearer $CMA_TOKEN" \
-H "Content-Type: application/json" \
"$API_BASE_URL/spaces/$SPACE_ID/environments/$ENVIRONMENT_ID/data_assemblies/$DA_ID" \
-d "$BODY")
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
if [ "$HTTP_CODE" = "409" ]; then
VERSION=$(curl -s -H "Authorization: Bearer $CMA_TOKEN" \
"$API_BASE_URL/spaces/$SPACE_ID/environments/$ENVIRONMENT_ID/data_assemblies/$DA_ID" \
| python3 -c "import sys, json; print(json.load(sys.stdin)['sys']['version'])")
curl -s -X PUT \
-H "Authorization: Bearer $CMA_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Contentful-Version: $VERSION" \
"$API_BASE_URL/spaces/$SPACE_ID/environments/$ENVIRONMENT_ID/data_assemblies/$DA_ID" \
-d "$BODY"
fiYou MUST produce this table at the end of every run. Do not skip it. This is the deliverable that confirms all bindings are accounted for.
After all DAs are created, published, and linked, output a complete binding map covering EVERY component type in the space that has content properties:
╔══════════════════════════════════════════════════════════════════════════════╗
║ COMPLETE BINDING MAP ║
╠══════════════════════════════════════════════════════════════════════════════╣
Component Type: HeroBanner (coded)
DA: hero-banner-page-hero ✓ created ✓ published ✓ linked
┌─────────────────┬────────────────────┬───────────────────────────────────┐
│ Content Property │ Source Field │ Return Path │
├─────────────────┼────────────────────┼───────────────────────────────────┤
│ title (String) │ pageHero.headline │ $resolvers/r1 → _node/headline │
│ subtitle (Str) │ pageHero.subline │ $resolvers/r1 → _node/subline │
│ image (Record) │ pageHero.bgImage │ $resolvers/r1 → _node/bgImage/* │
│ ctaLabel (Str) │ pageHero.ctaText │ $resolvers/r1 → _node/ctaText │
└─────────────────┴────────────────────┴───────────────────────────────────┘
Component Type: ProductSection (composite)
DA: product-section-data ✓ created ✓ published ✓ linked
┌─────────────────┬─────────────────────────┬────────────────────────────────┐
│ Content Property │ Source Field │ Return Path │
├─────────────────┼─────────────────────────┼────────────────────────────────┤
│ title (String) │ productSection.headline │ $resolvers/r1 → _node/headline │
└─────────────────┴─────────────────────────┴────────────────────────────────┘
Slots:
products → ProductGrid (has own DA: product-grid-catalog ✓)
Component Type: ProductGrid (coded, slot child)
DA: product-grid-catalog ✓ created ✓ published ✓ linked
┌─────────────────────────┬──────────────────────────┬───────────────────────┐
│ Content Property │ Source Field │ Return Path │
├─────────────────────────┼──────────────────────────┼───────────────────────┤
│ trendingProducts (Array)│ catalog.productsCol.items│ $resolvers/r1 → ... │
└─────────────────────────┴──────────────────────────┴───────────────────────┘
Component Type: Divider (coded)
DA: none needed (no content properties)
╠══════════════════════════════════════════════════════════════════════════════╣
║ SUMMARY ║
╠══════════════════════════════════════════════════════════════════════════════╣
Total component types: 4
DAs created: 3
DAs skipped (no content props): 1
Unbound content properties: 0 ← MUST be 0 to complete
╚══════════════════════════════════════════════════════════════════════════════╝Rules for the binding map:
After the map, print:
{list of DA IDs}Source