Domain skill
loom
Markdown synced from browser-harness domain skills.
- Host
- loom
- Files
- 1
Agent prompt
Use this skill
Copy this prompt into your coding agent to make it enable browser-harness domain skills and read this exact domain folder before automating.
Set up https://github.com/browser-use/browser-harness for me if it is not already installed. If setup is needed, read `install.md` first to install and connect it to my real browser. Then read `SKILL.md` for normal usage and always read `helpers.py` because that is where the browser-harness functions are. Enable domain skills if they are not already enabled by setting `BH_DOMAIN_SKILLS=1` for browser-harness. Use the `loom` domain skill from `agent-workspace/domain-skills/loom/`. Read every markdown file for this domain before inventing an approach: - agent-workspace/domain-skills/loom/folder-enumeration.md Use those domain-skill notes to complete my task for `loom` in my real browser. When you open a setup, verification, or task tab, activate it so I can see the active browser tab.
Skill contents
What the agent will read
Library Folder Enumeration
folder-enumeration.md
- Field-tested against loom.com on 2026-04-26. For private workspace library folders that require an authenticated session.
- Loom has two kinds of folder URLs and they need different tools:
- The library variant is what every Loom user sees in their own workspace sidebar. There is no public read API for it; the only programmatic route is the authenticated session in the user's browser. That's why this skill...
- For the download itself (after enumeration), yt-dlp --cookies-from-browser chrome -f http-transcoded is the fast path — a single HTTP MP4 stream rather than the 125 HLS fragments the default selection grabs....
Show full markdown
Field-tested against loom.com on 2026-04-26. For private workspace library folders that require an authenticated session.
TL;DR — When to use this skill vs yt-dlp
Loom has two kinds of folder URLs and they need different tools:
| URL pattern | Visibility | Tool |
|---|---|---|
loom.com/share/folder/<32-hex> | Public-shared (anyone with link) | yt-dlp — LoomFolderIE already supports this. Skip browser-harness entirely. |
loom.com/looms/videos/<slug>-<32-hex> | Private workspace library | browser-harness (this skill). yt-dlp doesn't support library folders, and the underlying /v1/folders/<id> endpoint returns Forbidden even with cookies. |
The library variant is what every Loom user sees in their own workspace sidebar. There is no public read API for it; the only programmatic route is the authenticated session in the user's browser. That's why this skill exists.
For the download itself (after enumeration), yt-dlp --cookies-from-browser chrome -f http-transcoded is the fast path — a single HTTP MP4 stream rather than the ~125 HLS fragments the default selection grabs. Substantially faster for bulk runs. See "Pipe to yt-dlp" at the bottom.
1. Attach to the user's open Loom tab
Always attach to the existing tab. Do not call new_tab() for loom.com — it spawns duplicate tabs in the user's Chrome profile (observed: four duplicate Loom tabs accumulated in one debugging session). The user has to clean those up manually afterwards.
import time
tabs = cdp("Target.getTargets")
loom_tid = next(
(t["targetId"] for t in tabs.get("targetInfos", [])
if "loom.com/looms/videos/" in t.get("url", "")),
None,
)
if not loom_tid:
raise SystemExit("User must open the Loom library folder in Chrome first.")
switch_tab(loom_tid)
time.sleep(0.3)
If multiple Loom tabs of the same folder are already open (common after a few sessions), pick the freshest one and close the others with cdp("Target.closeTarget", targetId=tid) before scrolling — keeps the user's Chrome tidy and avoids future ambiguity in Target.getTargets.
2. Selector — [data-videoid]
Each video card is an <article data-videoid="<32-hex>">. Inside, the first text line is the title (with two ARIA prefix/suffix strings to strip):
items = js("""
Array.from(document.querySelectorAll("[data-videoid]")).map(e => [
e.getAttribute("data-videoid"),
(e.innerText || "")
.split("\\n")[0]
.replace(/^Add /, "")
.replace(/ for bulk actions$/, "")
.trim()
])
""")
# items: [[id, title], ...] for the cards currently rendered
The visible <a href> on the card points at loom.com/share/<id>, so once you have the ID you can hand it straight to yt-dlp.
3. The virtualization quirk — scrollIntoView, NOT scrollTop
Loom's library uses an aggressive virtual scroller that:
- Renders ~30–60 cards at a time
- Caps
document.scrollingElement.scrollTopto a value far smaller thanscrollHeightas long as the bottom of the list isn't the bottom of the viewport - Unmounts cards above the viewport once you scroll past them
Setting scrollTop = N directly silently fails (the value snaps back) once you hit the cap. window.scrollTo behaves the same way. Mouse-wheel and PageDown via CDP weren't fully tested in our run, but given they end up at the same scrollingElement they're unlikely to escape the cap either.
The reliable mechanic is to take the last currently rendered card and scroll it into view at the bottom — the virtual scroller responds by mounting the next batch below it:
import time
ids_seen = {}
prev = -1; stuck = 0
js("document.scrollingElement.scrollTop = 0")
time.sleep(0.8)
for i in range(80):
items = js("""
Array.from(document.querySelectorAll("[data-videoid]")).map(e => [
e.getAttribute("data-videoid"),
(e.innerText||"").split("\\n")[0]
.replace(/^Add /, "").replace(/ for bulk actions$/, "").trim()
])
""")
for id_, title in (items or []):
ids_seen[id_] = title
js("""
(() => {
const a = document.querySelectorAll("[data-videoid]");
if (a.length) a[a.length - 1].scrollIntoView({block: "end"});
})()
""")
time.sleep(0.6)
if len(ids_seen) == prev:
stuck += 1
else:
stuck = 0
prev = len(ids_seen)
if stuck > 12:
break
print(f"collected {len(ids_seen)} videos")
Empirical numbers from one test run on a 78-video folder:
scrollTop-based scrolling: stuck at 60 of 78 (cap hit atscrollTop ≈ 2967whilescrollHeightwas4529).scrollIntoView-based scrolling: 78 of 78 in a single pass;scrollHeightgrew to5884as the virtualizer extended.
The stuck counter (12 idle iterations) is the right signal for "done" — paging.total style metadata is not exposed in the DOM, and the visible "78 videos" header at the top is a separate widget that does not refresh after scroll.
4. Endpoints that look promising but don't help
For completeness — a few dead ends so the next agent doesn't waste time:
https://www.loom.com/v1/folders/<id>?limit=10000— works for/share/folder/...IDs (this is whatLoomFolderIEuses), returnsForbiddenfor library folder IDs even with the user's cookies.https://www.loom.com/graphql— fires hundreds of times during page load. A folder-listing operation almost certainly lives in there, but thequerystrings come from the bundled React app and would have to be reverse-engineered from the JS bundle. Likely brittle long-term. Reading the rendered DOM is more durable.performance.getEntriesByType("resource")— useful for proving these endpoints exist, but only returns URLs/timings, not request bodies.
5. Pipe to yt-dlp for the actual download
The DOM scrape gives you IDs. Hand them to yt-dlp for the bytes — don't try to grab MP4 URLs yourself. yt-dlp already knows the GraphQL flow for single videos (LoomIE), handles CDN signature URLs, and merges audio + video tracks.
# One-time: cache cookies from Chrome (saves ~2s/video on bulk runs)
yt-dlp --cookies-from-browser chrome --cookies /tmp/loom_cookies.txt \
--skip-download --no-warnings \
"https://www.loom.com/share/<any-known-id>" >/dev/null
# Bulk: 16 videos in parallel, single-stream 1080p MP4 (~10× faster than HLS default)
download_one() {
yt-dlp --cookies /tmp/loom_cookies.txt \
-f http-transcoded \
-o "%(title)s.%(ext)s" \
--no-progress --no-warnings --no-mtime --no-overwrites \
"https://www.loom.com/share/$1"
}
export -f download_one
cat /tmp/loom_ids.json \
| python3 -c "import sys, json; [print(k) for k in json.load(sys.stdin)]" \
| xargs -P 16 -I {} bash -c 'download_one "$@"' _ {}
Format notes:
-f http-transcodedis a single HTTP MP4 stream at 1920×1080. The default selection pickshls-raw-3200+hls-raw-audio-audio, which is also 1080p but split into ~125 fragments per video. For bulk runs the single-stream form is dramatically faster (one TCP connection per video at full bandwidth, no per-fragment overhead). For a single video the difference is negligible.- Loom does not currently expose anything above 1080p for transcoded videos.
--no-overwritesmakes the bulk job idempotent: re-running picks up only what's missing.
Gotchas
- Two different folder URL families.
loom.com/share/folder/<id>(public) is yt-dlp territory.loom.com/looms/videos/<slug>-<id>(library) needs this skill. Don't mix them up — it's the difference between a one-line yt-dlp call and a DOM scrape. scrollTopis silently capped. Always usescrollIntoView({block: "end"})on the last rendered card. SettingscrollTopplateaus before the bottom of the list and gives you a partial enumeration that looks complete because the loop hits its idle threshold.- Never
new_tab()for Loom. Attach to the user's existing tab viaTarget.getTargets. New tabs accumulate in the user's Chrome profile across sessions, and the user has to clean them up. - Idle-counter is the reliable end-of-list signal. The "N videos" count in the page header may or may not be in sync with what's actually rendered (we didn't fully verify either direction). Use
len(ids_seen)going N iterations without growing as the stop condition rather than reading the header. - Title strings are wrapped in ARIA noise. The first line of
innerTextisAdd <title> for bulk actions. Strip both prefix and suffix before using as a filename. - One MP4 per ID via
loom.com/share/<id>. Library-internal share links are valid and yt-dlp accepts them — no need to transform IDs into anything fancier.