Recipe: collections (content groups) — create, fill with confined assets, share, clean up

Recipe: collections (content groups) — create, fill with confined assets, share, clean up

Recipe: collections (content groups) — create, fill with confined assets, share, clean up

💡

Prompt example

Create a collection called "Project Reel", add my two confined clips to it, rename it, then delete it.

In the Nomad UI a collection is exactly a content group at the API layer — a UI "collection" maps to the contentGroup REST endpoints. So vibe prompts that say "collection" map to the *_content_group SDK verbs.

A content group is a GLOBAL portal/account object — it is not under the run-root folder, so the folder-cascade teardown cannot reclaim it. Everything here is Class C (non-prod only): create the group, register delete_content_group for fail-closed teardown, and only ever add confined (run-root) asset ids so the assets themselves stay reclaimable by the folder cascade.

Key facts

  • Portal apiType only. Every content-group verb raises InvalidAPITypeException on an admin session. Use a portal session.
  • Group shape (live). Create returns {id, name, sharedUsers: [], contents: []} — no shares/isShared/sequence. After add, each contents[] item is {id, title, properties} (not a {id, description} lookup). get_content_groups is typed dict | None but the SDK docstring iterates it as a list of {id, name}. See reference/return-shapes.md.
  • Split-host portal. Content-group routes are portal-only, and the portal API may be a different host than admin. Set portalApiUrl in the env config so the portal session targets the right host — otherwise every portal route 404s → None.
  • Add/remove take a list, even for a single id. Removing a content does not delete the asset.
  • Share verbs are PLURAL (share_content_group_with_users / …with_users). An older singular form …_with_user does not exist; the live SDK method is plural.

Python

# components: create_content_group, get_content_group, add_contents_to_content_group,
#             remove_contents_from_content_group, rename_content_group, delete_content_group
def create_collection(sdk, name):
    """Create a content group ('collection'); return its id. CLASS C — non-prod only.

    Caller must register delete_content_group(sdk, id) for fail-closed teardown.
    """
    group = create_content_group(sdk, name)
    group_id = (group or {}).get("id")
    if not group_id:
        raise RuntimeError(f"create_content_group returned no id for {name!r}")
    return group_id

def add_confined_assets(sdk, group_id, asset_ids):
    """Add confined (run-root) asset ids to the group, then confirm membership."""
    add_contents_to_content_group(sdk, group_id, list(asset_ids))
    contents = (get_content_group(sdk, group_id) or {}).get("contents") or []
    member_ids = {c.get("id") for c in contents}
    assert set(asset_ids) <= member_ids, "asset(s) not in collection after add"
    return member_ids

def rename_collection(sdk, group_id, new_name):
    """Rename the group (returns None); confirm via a read-back."""
    rename_content_group(sdk, group_id, new_name)
    assert (get_content_group(sdk, group_id) or {}).get("name") == new_name

JavaScript

// components: createContentGroup, getContentGroup, addContentsToContentGroup,
//             removeContentsFromContentGroup, renameContentGroup, deleteContentGroup
export async function createCollection(sdk, name) {
    // CLASS C — non-prod only. Register deleteContentGroup(sdk, id) for teardown.
    const group = await createContentGroup(sdk, name);
    const groupId = group?.id;
    if (!groupId) throw new Error(`createContentGroup returned no id for '${name}'`);
    return groupId;
}

export async function addConfinedAssets(sdk, groupId, assetIds) {
    await addContentsToContentGroup(sdk, groupId, [...assetIds]);
    const contents = (await getContentGroup(sdk, groupId))?.contents ?? [];
    const memberIds = new Set(contents.map((c) => c.id));
    if (!assetIds.every((id) => memberIds.has(id))) throw new Error("asset(s) not in collection after add");
    return memberIds;
}

export async function renameCollection(sdk, groupId, newName) {
    await renameContentGroup(sdk, groupId, newName);
    if ((await getContentGroup(sdk, groupId))?.name !== newName) throw new Error("rename not applied");
}

Notes

  • Teardown is explicit. No folder cascade reaches a content group, so the only cleanup is delete_content_group(id). Defer it fail-closed (e.g. the test cleanup_registry) at create time.
  • Sharing with users. share_content_group_with_users(id, [user_id, …]) grants access; reverse with stop_sharing_content_group_with_users. Both return None; confirm via the group's sharedUsers / isShared. Needs real Nomad user ids.
  • Portal session required. If you only have an admin session, these calls raise InvalidAPITypeException — switch to a portal-apiType session.
  • get_portal_groups([...names]) reads named buckets (savedSearches, contentGroups, sharedContentGroups) in one call — useful to confirm a new group/ share landed.