| name | remote-compute |
| description | Run arbitrary scripts on KBase compute nodes via the CDM Task Service (CTS). Use when the user needs to move compute off their notebook or local machine — e.g., running bioinformatics tools, heavy data processing, or anything that benefits from dedicated CPU/memory on a remote node. |
| allowed-tools | Bash, Read |
| user-invocable | true |
Remote Compute Skill
Run containerized jobs on KBase compute nodes via the CDM Task Service (CTS).
Overview
CTS runs containerized jobs on remote compute clusters. Two execution paths are available:
| Path | When to use |
|---|
| Pre-built tool image | The tool you need is already in the registry (CheckM2, Bakta, GTDB-Tk, etc.) — just pass args |
| Generic ubuntu script | Tool not in registry, or you need custom logic — write a bash script, install deps at runtime |
Always discover the live image registry first before deciding which path to take:
tscli = get_task_service_client()
tscli.get_images()
AUTH_TOKEN=$(grep "KBASE_AUTH_TOKEN" .env | cut -d'"' -f2)
curl -s -H "Authorization: Bearer $AUTH_TOKEN" \
https://berdl.kbase.us/apis/cts/images | python3 -m json.tool
The registry grows over time — always check live rather than relying on any static list in this document.
When to use remote compute vs JupyterHub:
- JupyterHub: Interactive analysis, Spark SQL queries, exploratory work
- Remote compute: Batch processing, CPU/memory-intensive tools (CheckM2, Bakta, GTDB-Tk), long-running jobs, anything that would block your notebook
Preconditions
KBASE_AUTH_TOKEN set in environment or .env
- MinIO client (
mc) installed — see berdl-minio skill for installation
- Proxy running (when off-cluster) — MinIO is not directly reachable from external networks. See the berdl-query skill's
references/proxy-setup.md. The CTS API itself (https://berdl.kbase.us/apis/cts) does NOT require a proxy — only MinIO data staging does.
- CTS access and your S3 paths — run
whoami before staging any files. It confirms your
role and tells you the exact S3 path prefixes you are allowed to use for input_files and
output_dir. Every example in this skill uses cts/io/<username>/... as a placeholder —
whoami tells you what to substitute.
tscli = get_task_service_client()
tscli.whoami()
curl -s -H "Authorization: Bearer $AUTH_TOKEN" \
https://berdl.kbase.us/apis/cts/whoami | python3 -m json.tool
If roles is empty or does not include cts_user or full_admin, you do not have CTS access —
ask a CTS admin to grant the cts_user role.
Two Interfaces
Python client — JupyterHub only
tscli = get_task_service_client()
minio = get_minio_client()
REST API (curl) — any environment
All endpoints at https://berdl.kbase.us/apis/cts. Auth via Bearer token:
AUTH_TOKEN=$(grep "KBASE_AUTH_TOKEN" .env | cut -d'"' -f2)
Stage Input Files to MinIO
This step is required for both Path A and Path B. CTS does not read files from your local
machine or from the BERDL Lakehouse directly — all input files must be pre-uploaded to MinIO
before job submission. Every file must carry a CRC64NVME checksum or the submission will be
rejected.
Find your MinIO path prefix
Run tscli.whoami() (see Preconditions) to find your allowed_paths. Your input and output
directories should be nested under the writable path — typically cts/io/<username>/.
Upload with the MinIO CLI (mc)
export https_proxy=http://127.0.0.1:8123
export no_proxy=localhost,127.0.0.1
mc cp --checksum crc64nvme genome.fna.gz berdl-minio/cts/io/<username>/input/
mc cp --checksum crc64nvme --recursive ./input_genomes/ berdl-minio/cts/io/<username>/input/
mc ls berdl-minio/cts/io/<username>/input/
The berdl-minio alias must be configured. See the berdl-minio skill for setup instructions.
Upload from Python (JupyterHub)
minio = get_minio_client()
import os
local_file = "genome.fna.gz"
minio_path = f"io/<username>/input/{local_file}"
minio.fput_object("cts", minio_path, local_file)
print(f"Staged: cts/{minio_path}")
for obj in minio.list_objects("cts", prefix="io/<username>/input/", recursive=True):
print(obj.object_name, obj.size)
Build your input_files list
After staging, construct the input_files list for submit_job from the staged paths:
input_files = [
"cts/io/<username>/input/genome1.fna.gz",
"cts/io/<username>/input/genome2.fna.gz",
]
minio = get_minio_client()
objs = list(minio.list_objects("cts", prefix="io/<username>/input/", recursive=True))
input_files = [f"cts/{o.object_name}" for o in objs]
print(f"{len(input_files)} files staged and ready")
Path A: Pre-built Tool Images
Pre-built images have the tool already installed and set as the container entrypoint. You do not write a bash script — you pass arguments directly to the tool. Some images include large reference databases (refdata) that CTS mounts automatically.
What is refdata?
Some tools require large reference databases (e.g., CheckM2's marker gene sets, GTDB-Tk's genome sketches, Bakta's annotation database). These are pre-staged by the CTS admins and mounted read-only into the container at a fixed path. The image registration records which refdata bundle it needs and where to mount it (default_refdata_mount_point).
You do not pass a refdata_id to submit_job — it is determined by the image. You only need to know the mount path so you can reference it in your args if needed (e.g., --db /ref_data). Most images bake the path into the tool config or environment variables so you do not need to reference it at all — see the per-tool notes below.
If you want to override the default mount point, pass refdata_mount_point="/your/path" inside the params via the Python client's submit_job call. This is rarely needed.
General pattern — pre-built image
tscli = get_task_service_client()
job = tscli.submit_job(
"<image>:<tag>",
input_files,
"cts/io/<user>/output/run_name",
cluster="kbase",
output_mount_point="/out",
args=[...],
num_containers=1,
cpus=4,
memory="8GB",
runtime="PT30M",
)
print(f"Job ID: {job.id}")
Available pre-built images
| Tool | Image name pattern | Refdata | One container per file? | Special notes |
|---|
| CheckM2 | cdm_checkm2 | Yes (/ref_data) | No — batch | Auto-imports to Spark table; use declobber=True |
| MMseqs2 | cdm_mmseqs2 | No | No — all-at-once for clustering | Subcommand is first arg |
| KofamScan | cdm_kofamscan | Yes (/ref_data/profiles, /ref_data/ko_list) | No — batch | Must pass profile/ko_list paths explicitly |
| PSORTb | cdm_psortb | No | Yes | num_containers=len(input_files) |
| Bakta | cdm_bakta | Yes (/ref_data/db) | Yes | --force REQUIRED; BAKTA_DB auto-set |
| Bakta proteins | cdm_bakta_proteins | Yes (/ref_data/db) | Yes | --force REQUIRED; BAKTA_DB auto-set |
| GTDB-Tk | cdm_gtdbtk | Yes (/ref_data/release232) | No — always 1 | memory="128GB" minimum; num_containers=1 always |
| Skani | cdm_skani | No | No | Subcommand is first arg |
| Skani GTDB | cdm_skani_gtdb | Yes (/ref_data/release232/skani/database/) | No — batch | Must pass -d path explicitly |
| IQ-TREE 2 | cdm_iqtree | No | No — 1 per alignment | --prefix /out/tree; --redo recommended |
Full per-tool examples, resource guidance, and special notes are in individual reference files under references/. Each tool has its own file named after the image pattern without the cdm_ prefix:
| Tool | Reference file |
|---|
| CheckM2 | references/checkm2.md |
| MMseqs2 | references/mmseqs2.md |
| KofamScan | references/kofamscan.md |
| PSORTb | references/psortb.md |
| Bakta | references/bakta.md |
| Bakta proteins | references/bakta_proteins.md |
| GTDB-Tk | references/gtdbtk.md |
| Skani | references/skani.md |
| Skani GTDB | references/skani_gtdb.md |
| IQ-TREE 2 | references/iqtree.md |
Claude should Read only the specific tool's reference file after identifying the user's tool from get_images().
Path B: Generic Ubuntu Script
Use this path when your tool is not in the pre-built registry, or when you need custom logic that doesn't fit a single tool invocation. You write a bash script, upload it alongside your data, and the container executes it.
The generic image (ghcr.io/kbasetest/cts_ubuntu_test:0.1.0) is a minimal Ubuntu — Python, pip, and other tools are NOT pre-installed. Your script installs what it needs at runtime.
Step 1: Write your script
#!/bin/bash
apt-get update -qq && apt-get install -y -qq python3 python3-pip > /dev/null 2>&1
pip install biopython pandas --quiet
OUTPUT_DIR="$1"
shift
for FILE in "$@"; do
BASENAME=$(basename "$FILE")
python3 -c "
import Bio.SeqIO
records = list(Bio.SeqIO.parse('$FILE', 'fasta'))
print(f'{len(records)} sequences in $BASENAME')
" > "${OUTPUT_DIR}/${BASENAME}.result.txt"
done
Step 2: Upload script and data to MinIO
Stage your input data using the instructions in Stage Input Files to MinIO above.
Your bash script must also be uploaded — it is treated as an input file by CTS and staged
alongside your data.
mc cp --checksum crc64nvme my-analysis.sh berdl-minio/cts/io/<username>/scripts/
mc cp --checksum crc64nvme input_data/*.fna.gz berdl-minio/cts/io/<username>/input/
Step 3: Submit the job
Python client (JupyterHub)
tscli = get_task_service_client()
job = tscli.submit_job(
"ghcr.io/kbasetest/cts_ubuntu_test:0.1.0",
[
"cts/io/<username>/scripts/my-analysis.sh",
"cts/io/<username>/input/genome1.fna.gz",
"cts/io/<username>/input/genome2.fna.gz",
],
"cts/io/<username>/output/run_name",
cluster="kbase",
input_mount_point="/in",
output_mount_point="/out",
args=[
"/in/my-analysis.sh",
"/out/",
tscli.insert_files(),
],
num_containers=1,
cpus=4,
memory="8GB",
runtime="PT30M",
)
print(f"Job ID: {job.id}")
REST API (curl)
curl -s -X POST \
-H "Authorization: Bearer $AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"cluster": "kbase",
"image": "ghcr.io/kbasetest/cts_ubuntu_test:0.1.0",
"params": {
"args": [
"/in/my-analysis.sh",
"/out/",
{"type": "input_files", "input_files_format": "space_separated_list"}
],
"input_mount_point": "/in",
"output_mount_point": "/out"
},
"input_files": [
"cts/io/<username>/scripts/my-analysis.sh",
"cts/io/<username>/input/genome1.fna.gz",
"cts/io/<username>/input/genome2.fna.gz"
],
"output_dir": "cts/io/<username>/output/run_name",
"num_containers": 1,
"cpus": 4,
"memory": "8GB",
"runtime": "PT30M"
}' \
https://berdl.kbase.us/apis/cts/jobs
Job Management and Results
Step 1: Save the job ID immediately after submission
The job ID is the only handle you have on a submitted job. Save it before doing anything else.
job = tscli.submit_job(...)
print(f"Job ID: {job.id}")
with open("projects/<id>/data/cts_job_id.txt", "a") as f:
f.write(f"{job.id}\n")
Step 2: Check job status
job.get_job_status()
curl -s -H "Authorization: Bearer $AUTH_TOKEN" \
https://berdl.kbase.us/apis/cts/jobs/{job_id}/status
To block until the job finishes (useful in a notebook thread):
import threading, json
thread = threading.Thread(
target=lambda: print(json.dumps(job.wait_for_completion(), indent=4)),
daemon=True
)
thread.start()
Spark auto-import — some images (currently confirmed: CheckM2) have a registered event
importer on the CTS side. When the job finishes, this pipeline automatically reads the output
files from MinIO and loads them into a BERDL Iceberg table, tagged by cts_job_id. You can
then query results directly with Spark instead of downloading files manually.
Pass wait_for_event_importer=True to block until both the job and the import pipeline are done:
thread = threading.Thread(
target=lambda: print(json.dumps(
job.wait_for_completion(wait_for_event_importer=True), indent=4
)),
daemon=True
)
thread.start()
If the image does not have an importer, omit wait_for_event_importer=True and retrieve
results from MinIO manually (see Step 4 below).
Step 3: Check exit codes on failure
When get_job_status() returns "error", check exit codes first — it tells you which containers failed without pulling full logs.
job.get_exit_codes()
curl -s -H "Authorization: Bearer $AUTH_TOKEN" \
https://berdl.kbase.us/apis/cts/jobs/{job_id}/exit_codes
Step 4: Read logs (especially on failure)
job.print_logs(container_num=0, stderr=True)
job.print_logs(container_num=0, stderr=False)
job.print_logs(container_num=1, stderr=True)
curl -s -H "Authorization: Bearer $AUTH_TOKEN" \
https://berdl.kbase.us/apis/cts/jobs/{job_id}/log/0/stderr
curl -s -H "Authorization: Bearer $AUTH_TOKEN" \
https://berdl.kbase.us/apis/cts/jobs/{job_id}/log/0/stdout
Step 5: Find output files after completion
The output location is the output_dir you passed to submit_job. After the job completes, the
full list of output files (with checksums) is available from the job object:
job_details = job.get_job()
for output in job_details["outputs"]:
print(output["file"])
print(output["crc64nvme"])
print(f"CPU hours used: {job_details.get('cpu_hours')}")
print(f"Max memory used: {job_details.get('max_memory')} bytes")
print(f"Output file count: {job_details.get('output_file_count')}")
Download all outputs to a local directory:
mc cp --recursive berdl-minio/cts/io/<username>/output/run_name/ ./results/
minio = get_minio_client()
objects = list(minio.list_objects("cts", prefix="io/<username>/output/run_name/", recursive=True))
for obj in objects:
print(obj.object_name)
response = minio.get_object("cts", "io/<username>/output/run_name/quality_report.tsv")
print(response.read().decode("utf-8"))
response.close()
response.release_conn()
Recovering a lost job ID
If your notebook closed or you lost the job ID, use list_jobs() to find it.
Results are sorted newest-first. Maximum 1000 jobs returned.
tscli = get_task_service_client()
result = tscli.list_jobs(limit=20)
for j in result["jobs"]:
image = f"{j['image']['name']}:{j['image']['tag']}"
submitted = j["transition_times"][0]["time"]
print(f"{j['id']} {j['state']:<30} {image:<55} {submitted}")
Filter by state to find jobs that are still running or recently failed:
result = tscli.list_jobs(state="job_submitted")
result = tscli.list_jobs(state="complete")
result = tscli.list_jobs(state="error")
result = tscli.list_jobs(after="2026-06-01T00:00:00Z")
result = tscli.list_jobs(before="2026-05-01T00:00:00Z")
result = tscli.list_jobs(state="complete", after="2026-05-01T00:00:00Z", before="2026-06-01T00:00:00Z")
result = tscli.list_jobs(cluster="kbase", state="complete", after="2026-06-01T00:00:00Z")
curl -s -H "Authorization: Bearer $AUTH_TOKEN" \
"https://berdl.kbase.us/apis/cts/jobs?limit=20"
curl -s -H "Authorization: Bearer $AUTH_TOKEN" \
"https://berdl.kbase.us/apis/cts/jobs?state=error&limit=20"
curl -s -H "Authorization: Bearer $AUTH_TOKEN" \
"https://berdl.kbase.us/apis/cts/jobs?state=complete&after=2026-06-01T00:00:00Z"
Once you have the job ID, reconstruct the job object:
job = tscli.get_job_by_id("paste-job-id-here")
job.get_job_status()
job.get_job()["outputs"]
job.print_logs(container_num=0, stderr=True)
Coordinating multiple jobs
This skill focuses on individual job submission, but common patterns when running a pipeline across tools:
bakta_job = tscli.submit_job("cdm_bakta:...", ...)
checkm2_job = tscli.submit_job("cdm_checkm2:...", ...)
job_ids = {"bakta": bakta_job.id, "checkm2": checkm2_job.id}
bakta_result = bakta_job.wait_for_completion()
checkm2_result = checkm2_job.wait_for_completion(wait_for_event_importer=True)
bakta_outputs = [o["file"] for o in bakta_result["outputs"]]
downstream_job = tscli.submit_job("cdm_kofamscan:...", bakta_outputs, ...)
Track all job IDs in a file so the chain survives notebook restarts.
Canceling a job
job.cancel()
job.get_job_status()
curl -s -X PUT -H "Authorization: Bearer $AUTH_TOKEN" \
https://berdl.kbase.us/apis/cts/jobs/{job_id}/cancel
Key Concepts
Job lifecycle
created → download_submitted → job_submitting → job_submitted
→ upload_submitting → upload_submitted → complete
OR → error
(cancel at any point → canceling → canceled)
insert_files() / input_files placeholder
In the Python client, tscli.insert_files() is a sentinel in the args list. At job runtime, CTS replaces it with the paths of the input files assigned to that container (space-separated). In the REST API, use:
{"type": "input_files", "input_files_format": "space_separated_list"}
Script is an input file (generic path only)
Your bash script is listed in input_files alongside your data. CTS stages it to the compute node at the input_mount_point, then the container executes it. Reference it in args using the staged path (e.g., /in/my-analysis.sh).
CRC64NVME checksums
Every file uploaded to MinIO for CTS must carry a CRC64NVME checksum. Always use mc cp --checksum crc64nvme when uploading. Files without checksums are rejected at job submission time.
Multi-container parallelism
Set num_containers > 1 to split input files across containers. CTS distributes files evenly. Each container receives its share via insert_files(). Use declobber=True when multiple containers could write output files with the same name (e.g., CheckM2 always writes quality_report.tsv). With declobber=True, CTS prepends the container index as a directory to every output path:
# declobber=True output layout (two containers):
cts/io/<username>/output/checkm2_run/0/quality_report.tsv
cts/io/<username>/output/checkm2_run/1/quality_report.tsv
Without declobber=True, container 1 would overwrite container 0's file.
Runtime format
ISO 8601 duration strings: "PT5M" (5 minutes), "PT1H" (1 hour), "PT2H30M" (2.5 hours), "PT4H" (4 hours). The format is case-sensitive — "PT1H" works, "pt1h" does not.
Image tags vs digest pinning
Every example in this skill uses image:tag format (e.g. cdm_bakta:0.1.3). Tags are
human-readable but mutable — a tag can be re-pushed to point to a different image without
warning. For reproducible science, pin to the digest instead:
ghcr.io/kbaseincubator/cdm_bakta:0.1.3@sha256:ab3d636d9381fdd57a8b21e3a2403663987f57f123027fa7a773474e66d9a827
This format uses both the tag (for readability) and the digest (for pinning). The container
runtime uses the digest to pull the exact image regardless of what the tag currently points to.
Get the current digest for any image from the live registry:
images = tscli.get_images()
for img in images["data"]:
if "bakta" in img["name"]:
print(f"{img['name']}:{img['tag']}@{img['digest']}")
curl -s -H "Authorization: Bearer $AUTH_TOKEN" \
https://berdl.kbase.us/apis/cts/images | python3 -c "
import sys, json
for img in json.load(sys.stdin)['data']:
tag = img.get('tag', 'none')
print(f\"{img['name']}:{tag}@{img['digest']}\")
"
Always retrieve the digest fresh from get_images() — do not rely on digests hardcoded in
notebooks or this skill, as they become stale when images are updated.
Refdata and mount points
refdata_id identifies which pre-staged reference database bundle the image needs. It is registered with the image — you do not pass it to submit_job. CTS mounts the refdata read-only into the container at the default_refdata_mount_point specified in the image registration (always /ref_data for current BERDL images). To override the mount path, pass refdata_mount_point="/your/path" to submit_job. This is rarely needed since all current images use /ref_data as their default.
To verify a refdata bundle is registered and ready before submitting:
tscli.get_images()
curl -s -H "Authorization: Bearer $AUTH_TOKEN" \
https://berdl.kbase.us/apis/cts/refdata | python3 -m json.tool
GPU support
GPU compute is not currently available on the kbase cluster. Do not pass GPU-related parameters — the submit_job call will succeed but no GPU will be allocated.
API Reference
| Endpoint | Method | Auth | Description |
|---|
/ | GET | Yes | Service info |
/whoami | GET | Yes | Your username, roles, and allowed S3 paths |
/images | GET | Yes | List approved container images (call this first) |
/refdata | GET | Yes | List registered reference data bundles |
/sites | GET | No | Available compute clusters |
/jobs | POST | Yes | Submit a job |
/jobs | GET | Yes | List your jobs (state, after, before, cluster, limit) |
/jobs/{id} | GET | Yes | Full job details (includes output files list) |
/jobs/{id}/status | GET | Yes | Lightweight status check |
/jobs/{id}/exit_codes | GET | Yes | Exit code per container (first check on error) |
/jobs/{id}/log/{n}/stdout | GET | Yes | Container N stdout |
/jobs/{id}/log/{n}/stderr | GET | Yes | Container N stderr |
/jobs/{id}/cancel | PUT | Yes | Cancel a running job |
Base URL: https://berdl.kbase.us/apis/cts
Error Handling
| Error | Meaning | Fix |
|---|
Job state error | Container failed | Check exit codes and stderr logs |
| Non-zero exit code | Script or tool failure | Read stderr: GET /jobs/{id}/log/0/stderr |
| Auth error (401) | Invalid or expired token | Refresh KBASE_AUTH_TOKEN in .env |
"you must be a KBase staff member" | Missing role for cluster | Ask CTS admin to grant staff role |
| Image not found / not approved | Image not in registry | Run GET /images to see approved images |
| S3 file rejected | Missing CRC64NVME checksum | Re-upload with mc cp --checksum crc64nvme |
"Connect call failed" on MinIO | Proxy not running | Start SSH tunnels + pproxy (see berdl-query proxy-setup) |
| OOM / container killed | Insufficient memory | Increase memory in submit_job |
Job stuck in download_submitted | Refdata staging in progress | Wait — large refdata bundles (GTDB-Tk, Bakta) take time to stage on first use |
bakta: error: directory ... already exists | Missing --force flag | Add "--force" to your args list |
Instructions for Claude
- Run
whoami and get_images before anything else — call tscli.whoami() to confirm CTS access and resolve the user's actual S3 path prefix (substituting it everywhere <username> appears in examples). Call tscli.get_images() to see currently registered images and retrieve their current digests. Do not rely on any static list in this skill.
- Determine the execution path — if the user's tool is in the registry, use Path A (pre-built image). If not, use Path B (generic ubuntu script).
- Always use digest-pinned image references — from the
get_images() response, use name:tag@sha256:digest format in every submit_job call (e.g. ghcr.io/kbaseincubator/cdm_bakta:0.1.3@sha256:ab3d...). Tell the user which tag and digest you are using. Never hardcode a digest from memory or this skill — always fetch it live.
- Ensure input files are staged before submitting — for both Path A and Path B, walk the user through uploading their input files to MinIO with
mc cp --checksum crc64nvme (or the Python MinIO client on JupyterHub). Confirm files are staged before building the submit_job call. Build the input_files list from the staged MinIO paths.
- For pre-built images —
Read the tool's reference file at .claude/skills/remote-compute/references/<toolname>.md (e.g. references/gtdbtk.md, references/bakta.md). The filename matches the image name pattern without the cdm_ prefix — the lookup table is in the "Available pre-built images" section above. Use the reference file as the starting template. Fill in the user's actual MinIO paths for input_files and output_dir. Help the user determine num_containers and resource parameters based on their input count and the per-tool guidance in that file.
- For the generic script path — help the user write a self-contained bash script, upload it with checksums alongside their data, and submit. The script should install its dependencies at the top.
- Before submitting — confirm with the user — summarize the job: tool, image+digest, input file count,
num_containers, memory, runtime, and output path. Ask the user to confirm before calling submit_job. This prevents wasted cluster time from wrong arguments.
- After submission — display the job ID prominently and save it to a file — print it clearly and write it to
projects/<id>/data/cts_job_id.txt (or equivalent). The user needs it to check status or retrieve results, and may lose it if the notebook closes.
- Monitor when asked — use
get_job_status() for lightweight polling. For blocking waits, use wait_for_completion() in a background thread. For CheckM2 (confirmed auto-import), use wait_for_completion(wait_for_event_importer=True) and then query the Spark table directly. For other images, check with the user or the CTS team before assuming an importer exists.
- Help find previous jobs — if the user lost their job ID or wants to check past runs, use
tscli.list_jobs() with appropriate filters (state, after, before, cluster). Once found, reconstruct the job object with tscli.get_job_by_id().
- On completion — tell the user where their results are — state the output location explicitly (the
output_dir passed to submit_job). List the actual output files from job.get_job()["outputs"]. Show the mc cp --recursive command to download them.
- On error — call
job.get_exit_codes() first to see which containers failed, then read stderr logs for those containers (job.print_logs(container_num=N, stderr=True)). The most common causes are wrong args, missing --force for Bakta, insufficient memory, or missing checksums.
- Retrieve and interpret results — help the user download outputs from MinIO and explain what the tool produced.
Pitfall Detection
When you encounter errors, unexpected results, retry cycles, performance issues, or data surprises during this task, follow the pitfall-capture protocol. Read .claude/skills/pitfall-capture/SKILL.md and follow its instructions to determine whether the issue should be added to the active project's projects/<id>/memories/pitfalls.md.