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 exampleCreate 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
InvalidAPITypeExceptionon an admin session. Use a portal session. - Group shape (live). Create returns
{id, name, sharedUsers: [], contents: []}— noshares/isShared/sequence. After add, eachcontents[]item is{id, title, properties}(not a{id, description}lookup).get_content_groupsis typeddict | Nonebut the SDK docstring iterates it as a list of{id, name}. Seereference/return-shapes.md. - Split-host portal. Content-group routes are portal-only, and the portal API may
be a different host than admin. Set
portalApiUrlin 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_userdoes 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_nameJavaScript
// 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 testcleanup_registry) at create time. - Sharing with users.
share_content_group_with_users(id, [user_id, …])grants access; reverse withstop_sharing_content_group_with_users. Both returnNone; confirm via the group'ssharedUsers/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.
