From histai-skillsets
Uploads whole slide images (WSIs) to the user's CellDX 'My Cases', installs and runs AI widgets on those slides, and retrieves results including segmentation masks. For users who want to analyze their own slides.
How this skill is triggered — by the user, by Claude, or both
Slash command
/histai-skillsets:slide_analyzerThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
This skill lets an agent drive the **end-to-end inference workflow** on CellDX using the user's own slides:
This skill lets an agent drive the end-to-end inference workflow on CellDX using the user's own slides:
CellDX has three independent workflows. Pick the right skill for the user's intent:
| Workflow | Skill | What it operates on | Costs |
|---|---|---|---|
| Buy public WSIs | cohort_builder | HistAI public catalogue (~220K+ slides) | Per-slide pricing ($5/H&E, $40/IHC) |
| Train a model | ai_model_trainer | Server-side feature store | GPU compute ($/GPU-hour) |
| Upload + run widgets (this skill) | slide_analyzer | The user's own slides in "My Cases" | Storage + analysis credits (per-run, no per-slide download fee) |
cohort_builder instead — do not upload anything./datahub/* from this skill. That namespace is for the public Datahub catalogue and uses a separate billing model.datasetId here with a Datahub cohort id. In this skill, a "case" is modelled as a Dataset and datasetId identifies a folder of the user's own slides.All requests require an API key.
export CELLDX_API_KEY="your-api-key"
Send it in the X-API-KEY header:
X-API-KEY: $CELLDX_API_KEY
Users generate API keys at Profile and settings → API keys on https://celldx.hist.ai.
https://prod.celldx.net/v1
All paths below are relative to that base.
Dataset) → collection of Slides (WSIs).GET /widgets/store)ai_model_trainer / training_monitor skills (see GET /custom-widgets)
Both must be installed before they can be run on a slide.analysisTaskId. Has a status enum: IN_QUEUE, PROCESSING, COMPLETED, FAILED, TIMED_OUT, ABORTED.GET /widgets/analyze/task/{id}, so a client can show the user "what the model is saying" without polling a separate chat endpoint.POST /files/upload/slide
X-API-KEY: ${CELLDX_API_KEY}
Content-Type: multipart/form-data
The request body is a multipart/form-data upload with exactly three parts (no other fields are read by the server):
| Part name | Kind | Value |
|---|---|---|
taskId | text form field | A client-generated UUID v4 that you mint for this upload. It becomes the handle for polling/aborting. |
datasetId | text form field | UUID of the case (Dataset) the slide should land in. Get it from GET /datasets or create one with POST /datasets. |
file | file part | The WSI binary. The filename is taken from this part's Content-Disposition: filename="…" header — there is no separate fileName field. |
Example with curl:
TASK_ID=$(uuidgen | tr 'A-Z' 'a-z')
curl -X POST "$BASE/files/upload/slide" \
-H "X-API-KEY: $CELLDX_API_KEY" \
-F "taskId=$TASK_ID" \
-F "datasetId=$DATASET_ID" \
-F "file=@/path/to/slide.svs"
Returns SlideUploadTaskDto (echoes the taskId you sent — use it to poll). The server runs the same checks as the web UI:
| Error code | Meaning |
|---|---|
INVALID_FILE_EXTENSION | Extension not in svs, tif, tiff, dcm, ndpi, vms, vmu, scn, mrxs, svslide, bif, jpg, jpeg, png, webp |
NOT_ENOUGH_USER_STORAGE_SPACE | Not enough storage quota for this file |
NOT_PYRAMIDAL | File is not a valid pyramidal WSI |
RESOURCE_NOT_ACCESSIBLE | Server could not read the file |
POST /files/upload/slide/byURL
X-API-KEY: ${CELLDX_API_KEY}
Content-Type: application/json
{ "link": "https://...", "datasetId": "<uuid>" }
Server-side SSRF guard rejects loopback / private / link-local hosts and non-http(s) schemes. Returns INVALID_URL for those.
GET /files/upload/slide/task/{taskId}
Returns { taskId, uploadProgress: 0..100, taskProgress: 0..100, status: UPLOADING|PROCESSING|COMPLETED|FAILED|ABORTED, name }. Poll every 2–5 seconds until status is terminal (COMPLETED/FAILED/ABORTED).
POST /files/upload/slide/abort?taskId={taskId}
GET /workspaces # list workspaces (with nested projects)
GET /projects?workspaceId=<uuid> # list projects in a workspace
GET /datasets # list ALL of the user's cases (flat)
GET /datasets/{datasetId} # case details including its slides
GET /images/{imageId} # single slide metadata (name, stain, magnification, status, …)
Use GET /datasets to find a case by name, then GET /datasets/{datasetId} to enumerate the slides inside — DatasetResponse.slides carries the list with slideIds. This is how you obtain the slideId to pass to /widgets/{id}/analyze.
DELETE /images/{imageId} # delete one slide
DELETE /datasets/{datasetId} # delete a case and all its slides
Ownership is enforced server-side — returns 404 if the slide/case isn't owned by the API-key user. These are irreversible. Always confirm with the user before calling.
GET /widgets # WidgetsResponse — public widgets with an `isInstalled` flag per widget
GET /widgets/store # only widgets the user has NOT installed
GET /widgets/installed # only the installed ones
GET /widgets/{widgetId} # widget details
GET /custom-widgets # user's custom (self-trained) widgets, same install model
A widget MUST be installed before it can be run. Custom widgets created via the AI Model Trainer flow are auto-installed on deployment, but public widgets typically are not.
POST /widgets/install/{widgetId} # install public widget
POST /widgets/uninstall/{widgetId} # uninstall public widget
POST /custom-widgets/install/{widgetId} # install custom widget
POST /custom-widgets/uninstall/{widgetId} # uninstall custom widget
POST /widgets/{widgetId}/analyze
X-API-KEY: ${CELLDX_API_KEY}
Content-Type: application/json
{
"slideId": "<uuid>",
"datasetId": "<uuid>",
"bbox": null, // or { x, y, width, height } to restrict to a region
"allChatResponse": false
}
Returns:
{ "analysisTaskId": "<uuid>" }
Common failure modes:
404 "Widget not found" — widget id is wrong or not installed for this user409 ANALYSIS_COUNTER_LIMIT_REACHED — user's per-period analysis quota is exhausted; tell the user to top up or waitGET /widgets/analyze/task/{analysisTaskId}
Returns:
{
"analysisTaskId": "<uuid>",
"slideId": "<uuid>",
"widgetId": "<uuid>",
"datasetId": "<uuid>",
"status": "IN_QUEUE" | "PROCESSING" | "COMPLETED" | "FAILED" | "TIMED_OUT" | "ABORTED",
"createdDate": "yyyy-MM-dd HH:mm:ss",
"analysisStartDate": "...",
"analysisEndDate": "...",
"layout": null | "<stringified JSON of the result>",
"message": null | {
"messageId": "<uuid>",
"type": "HTML" | "TEXT" | "PERSON_INFO",
"createdDate": "yyyy-MM-ddTHH:mm:ssZ",
"lastEditedDate": "yyyy-MM-ddTHH:mm:ssZ",
"content": "<string or null>"
}
}
layout is null until status == COMPLETED.layout is a JSON string with annotations (polygons), optional segmentation (mask data), depthPoints, diameterPoints, mitoses — parse it with JSON.parse(layout).message is the chat-style summary the backend attaches to the analysis (the same message the web UI shows in the slide's chat panel). It appears as soon as the task is created, so you can surface it to the user while the task is still IN_QUEUE/PROCESSING — its content will be updated by the server as the run progresses, and you can re-fetch the task to get newer content.
type = HTML → render as rich HTML (most widget summaries; tables, figures).type = TEXT → render as plain text.type = PERSON_INFO → structured demographics block; rare for widget runs.content may be null early in the lifecycle. Treat message == null as "no chat message bound to this run yet".message.createdDate / lastEditedDate use ISO-8601 with T/Z (yyyy-MM-ddTHH:mm:ssZ), unlike the surrounding createdDate / analysisStartDate / analysisEndDate fields which use space-separated yyyy-MM-dd HH:mm:ss. Parse them separately.Use this instead of a per-slide loop over POST /widgets/{id}/analyze when you want to apply one widget to a list of slides or whole cases. The server queues the work, reserves analysis quota up front, and exposes pause/resume/cancel/retry.
A batch job has its own lifecycle (independent of the per-slide analysisTaskId):
CREATED → QUEUED ⇄ RUNNING → COMPLETED | FAILED | CANCELLED
↕
PAUSED
Each per-slide item carries its own status (PENDING | IN_QUEUE | PROCESSING | COMPLETED | FAILED | SKIPPED | CANCELLED) plus the analysisTaskId for that slide — so once an item is COMPLETED you can fetch the layout/message the same way as a single-slide run (GET /widgets/analyze/task/{analysisTaskId}).
POST /batch/jobs
X-API-KEY: ${CELLDX_API_KEY}
Content-Type: application/json
{
"widgetId": "<uuid>", // required; must already be installed for the user
"slideIds": ["<uuid>", ...], // optional; OR
"datasetIds": ["<uuid>", ...], // optional; whole cases (all their slides)
"name": "Optional label",
"priority": "LOW" | "NORMAL" | "HIGH",
"skipAlreadyAnalyzed": true, // re-use prior COMPLETED runs instead of re-running
"templateId": "<uuid>",
"config": { ... } // widget-specific overrides
}
Pass slideIds, datasetIds, or both — they are de-duplicated. Every slide is ownership-checked against the API-key user; if you include a slide or dataset you don't own, the call fails with 409 ACCESS_DENIED. Widget must be installed (409 WIDGET_NOT_INSTALLED otherwise).
Returns 201 with:
{
"batchJobId": "<uuid>",
"name": "...",
"widgetId": "<uuid>",
"status": "CREATED",
"priority": "NORMAL",
"totalSlides": 42,
"completedSlides": 0,
"failedSlides": 0,
"skippedSlides": 0,
"quotaReserved": 0,
"quotaConsumed": 0,
"createdDate": "yyyy-MM-ddTHH:mm:ss",
"startedDate": null,
"completedDate": null,
"progressPercent": 0.0
}
The job is created in CREATED state — nothing runs until you call:
POST /batch/jobs/{batchJobId}/start
This reserves the user's common analysis quota for all non-skipped items (so the run can't overrun mid-flight) and kicks off async orchestration. Status moves to QUEUED, then RUNNING.
GET /batch/jobs/{batchJobId} # job + items + summary in one shot
GET /batch/jobs/{batchJobId}/items?page=0&size=100 # paged items only
GET /batch/jobs?page=0&size=50 # list all your jobs (newest first)
GET /batch/jobs/{id} returns:
{
"job": { ...BatchJobResponse... },
"items": [
{
"itemId": "<uuid>",
"slideId": "<uuid>",
"datasetId": "<uuid>",
"status": "PENDING|IN_QUEUE|PROCESSING|COMPLETED|FAILED|SKIPPED|CANCELLED",
"analysisTaskId": "<uuid or null>",
"errorMessage": "...",
"retryCount": 0,
"startedDate": "...",
"completedDate": "..."
}
],
"summary": {
"pendingCount": 5,
"inQueueCount": 0,
"processingCount": 2,
"completedCount": 30,
"failedCount": 1,
"skippedCount": 4,
"cancelledCount": 0,
"estimatedRemainingMinutes": 21.0
}
}
To fetch a per-slide result: once item.status == COMPLETED, hit GET /widgets/analyze/task/{item.analysisTaskId} — same endpoint, same layout / message shape as single-slide runs.
Poll cadence: 5–15 seconds. Use GET /batch/jobs/{id} for the small "is it done?" check, switch to /items only when you need per-slide detail.
POST /batch/jobs/{batchJobId}/pause # only from RUNNING or QUEUED
POST /batch/jobs/{batchJobId}/resume # only from PAUSED
POST /batch/jobs/{batchJobId}/cancel # CREATED/QUEUED/RUNNING/PAUSED → CANCELLED; aborts in-flight items, releases reserved quota
POST /batch/jobs/{batchJobId}/retry-failed # only from COMPLETED or FAILED; re-queues just the FAILED items (increments retryCount)
POST /widgets/{id}/analyze. No reason to spin up a job.1. POST /files/upload/slide (or /byURL) → { taskId }
2. loop: GET /files/upload/slide/task/{taskId} → wait until status=COMPLETED
3. GET /datasets/{datasetId} → find the slideId of the upload
4. GET /widgets → pick a widget; install if !isInstalled
5. POST /widgets/{widgetId}/analyze → { analysisTaskId }
6. loop: GET /widgets/analyze/task/{analysisTaskId} → wait until status=COMPLETED
(optional: surface `message.content` to the user while still IN_QUEUE / PROCESSING)
7. parse `layout` field for annotations / segmentation; render `message` (HTML/TEXT) for the chat-style summary
8. (optional) DELETE /images/{slideId} → free storage
1. (steps 1–2 of the single-slide flow, once per slide, or upload to a case ahead of time)
2. GET /widgets → pick widgetId; install if !isInstalled
3. POST /batch/jobs { widgetId, datasetIds:[<case>] } → { batchJobId, status: CREATED }
4. POST /batch/jobs/{batchJobId}/start → status moves to QUEUED → RUNNING
5. loop every 5–15s: GET /batch/jobs/{batchJobId}
→ watch progressPercent + summary
6. for each item with status=COMPLETED:
GET /widgets/analyze/task/{item.analysisTaskId} → layout + message
7. if any item.status=FAILED and worth retrying:
POST /batch/jobs/{batchJobId}/retry-failed
The API enforces per-key token-bucket rate limits. On 429 the response includes Retry-After (seconds), X-RateLimit-Limit, and X-RateLimit-Remaining. Indicative values:
| Endpoint | Burst | Sustained |
|---|---|---|
POST /files/upload/slide | 10 | 10/min |
POST /files/upload/slide/byURL | 20 | 20/min |
GET /files/upload/slide/task/{id} | 300 | 5/s |
GET /datasets, /workspaces, /projects, /widgets* | 120–240 | 1–2/s |
DELETE /datasets/{id}, DELETE /images/{id} | 30 | 15/min |
POST /widgets/{id}/analyze | 120 | 2/s |
GET /widgets/analyze/task/{id} | 600 | 10/s |
POST /batch/jobs, POST /batch/jobs/{id}/start, POST /batch/jobs/{id}/retry-failed | 30 | 15/min |
GET /batch/jobs | 120 | 60/min |
GET /batch/jobs/{id}, GET /batch/jobs/{id}/items | 240 | 2/s |
POST /batch/jobs/{id}/pause, /resume, /cancel | 60 | 30/min |
Do not poll faster than the table above. Respect Retry-After.
DELETE of a case or slide — deletion is irreversible and frees storage immediately.POST /widgets/{id}/analyze if the widget has a per-run charge — present the widget name and any displayed cost to the user first.POST /batch/jobs — multiply the per-run cost by the number of slides being queued and surface the total. POST /batch/jobs/{id}/start actually reserves quota, so creating but not starting is cheap.POST /batch/jobs/{id}/cancel) aborts in-flight per-slide runs. Items already COMPLETED keep their results; in-flight items end up CANCELLED and reserved-but-not-consumed quota is released.FAILED task status — surface the error to the user./files/upload/slide/byURL with internal-looking URLs (the server rejects them, but agents should not even try — it's wasted requests).ai_model_trainer + training_monitor skills. Once POST /v1/ml-jobs/jobs/{job_id}/deploy succeeds, the resulting widget appears in GET /custom-widgets and can be installed + run via this skill.cohort_builder)?Searches MemPalace before answering questions about past work, people, projects, or prior decisions. Returns verbatim stored content instead of guessing from model memory.
Guides Payload CMS config (payload.config.ts), collections, fields, hooks, access control, APIs. Debugs validation errors, security, relationships, queries, transactions, hook behavior.
Implements vector databases with Pinecone, Weaviate, Qdrant, Milvus, pgvector for semantic search, RAG, recommendations, and similarity systems. Optimizes embeddings, indexing, and hybrid search.
npx claudepluginhub histai/skillsets --plugin slide-analyzer