Drive Script Checker for deepTools from your own code
Script Checker for deepTools checks a deepTools bash script before you run it. In the page, and free, it splits
the script into commands, runs every deepTools command line through an emulation of deepTools
3.5.6's own argument parser (all 20 commands) and the argument-only exits in each tool's
main(), and adds best-practice checks that quote the deeptools agent skill's SKILL.md.
It also ports the skill's workflow_generator.py, so its four templates come out byte for byte.
The metered lanes read what the page found: review judges a script for the experiment you
describe and returns a corrected script; plan drafts a pipeline from a prose description.
Neither sees your data: nothing opens a BAM or BED file, and no reply may claim a result. The page
parses any script in a reply with the same deepTools 3.5.6 checks and shows every error it finds.
Two lanes: the task field
| task | send | you get back |
|---|---|---|
review | facts, script; context and question optional | status (blocked, fix_first, ready), headline, experiment_read, a stance on every flag (flag_responses), findings (severity, command, issue, why, fix), qc_checkpoints, corrected_script ("" when nothing needs to change) and to_confirm. |
plan | facts, experiment; context optional | status (always draft), headline, assumptions, steps (n, tool, purpose, why), normalization (method, why), the whole script, qc_checkpoints and to_confirm. |
Worked example: review
The skill's own ChIP-seq QC template, generated for a paired-end H3K4me3 experiment on GRCh38. The page finds one deepTools error: the template calls plotCorrelation without its required --whatToPlot/-p (and with --whatToShow, a plotHeatmap option), so deepTools 3.5.6 stops at step 2 with an argparse error.
{
"task": "review",
"facts": "{\"deeptools\":\"3.5.6\",\"experiment\":{\"assay\":\"chip\",\"layout\":\"paired\",\"assembly\":\"GRCh38\"},\"genome_sizes\":{\"assembly\":\"GRCh38\",\"read_lengths\":[50,75,100,150,200,250],\"deeptools_3_5_6\":{\"nonN\":2913022398,\"by_read_length\":[2701495711,2747877702,2805636231,2862010428,2887553103,2898802627]},\"current_docs\":{\"nonN\":3130250755,\"by_read_length\":[2708135512,2765133945,2820945813,2884242992,2909040054,2919128283]}},\"status\":\"blocked\",\"commands\":[{\"n\":1,\"line\":21,\"tool\":\"multiBamSummary bins\",\"parse\":\"ok\",\"exits\":[]},{\"n\":2,\"line\":27,\"tool\":\"plotCorrelation\",\"parse\":\"the following arguments are required: --whatToPlot/-p\",\"exits\":[]},{\"n\":3,\"line\":35,\"tool\":\"plotPCA\",\"parse\":\"ok\",\"exits\":[]},{\"n\":4,\"line\":42,\"tool\":\"plotCoverage\",\"parse\":\"ok\",\"exits\":[]},{\"n\":5,\"line\":50,\"tool\":\"bamPEFragmentSize\",\"parse\":\"ok\",\"exits\":[]},{\"n\":6,\"line\":57,\"tool\":\"plotFingerprint\",\"parse\":\"ok\",\"exits\":[]}],\"flags\":[{\"id\":\"F1\",\"source\":\"tool\",\"severity\":\"high\",\"code\":\"parse\",\"command\":2,\"text\":\"plotCorrelation: error: the following arguments are required: --whatToPlot/-p (argparse exits 2 before reading any file)\",\"quote\":\"\"}],\"flags_total\":1,\"generator\":{\"workflow\":\"chipseq_qc\",\"command_line\":\"python scripts/workflow_generator.py chipseq_qc -o chipseq_qc.sh --threads 8 --input-bam Input.bam --chip-bams 'H3K4me3_rep1.bam H3K4me3_rep2.bam'\"},\"script_sent\":{\"lines\":67,\"lines_cut\":0,\"chars_cut\":0}}",
"script": "#!/bin/bash\nset -euo pipefail\n\n# deepTools ChIP-seq Quality Control Workflow\n# Generated by deepTools workflow generator\n\n# Configuration\nINPUT_BAM=Input.bam\nCHIP_BAM=(H3K4me3_rep1.bam H3K4me3_rep2.bam)\nGENOME_SIZE=2913022398\nTHREADS=8\nOUTPUT_DIR=chipseq_qc_output\n\n# Create output directory\nmkdir -p \"$OUTPUT_DIR\"\n\necho \"=== Starting ChIP-seq QC workflow ===\"\n\n# Step 1: Correlation analysis\necho \"Step 1: Computing correlation matrix...\"\nmultiBamSummary bins \\\n --bamfiles \"$INPUT_BAM\" \"${CHIP_BAM[@]}\" \\\n -o \"$OUTPUT_DIR/readCounts.npz\" \\\n --numberOfProcessors \"$THREADS\"\n\necho \"Step 2: Generating correlation heatmap...\"\nplotCorrelation \\\n -in \"$OUTPUT_DIR/readCounts.npz\" \\\n --corMethod pearson \\\n --whatToShow heatmap \\\n --plotFile \"$OUTPUT_DIR/correlation_heatmap.png\" \\\n --plotNumbers\n\necho \"Step 3: Generating PCA plot...\"\nplotPCA \\\n -in \"$OUTPUT_DIR/readCounts.npz\" \\\n -o \"$OUTPUT_DIR/PCA_plot.png\" \\\n -T \"PCA of ChIP-seq samples\"\n\n# Step 2: Coverage assessment\necho \"Step 4: Assessing coverage...\"\nplotCoverage \\\n --bamfiles \"$INPUT_BAM\" \"${CHIP_BAM[@]}\" \\\n --plotFile \"$OUTPUT_DIR/coverage.png\" \\\n --ignoreDuplicates \\\n --numberOfProcessors \"$THREADS\"\n\n# Step 3: Fragment size (for paired-end data)\necho \"Step 5: Analyzing fragment sizes...\"\nbamPEFragmentSize \\\n --bamfiles \"$INPUT_BAM\" \"${CHIP_BAM[@]}\" \\\n --histogram \"$OUTPUT_DIR/fragmentSizes.png\" \\\n --plotTitle \"Fragment Size Distribution\"\n\n# Step 4: ChIP signal strength\necho \"Step 6: Evaluating ChIP enrichment...\"\nplotFingerprint \\\n --bamfiles \"$INPUT_BAM\" \"${CHIP_BAM[@]}\" \\\n --plotFile \"$OUTPUT_DIR/fingerprint.png\" \\\n --extendReads 200 \\\n --ignoreDuplicates \\\n --numberOfProcessors \"$THREADS\" \\\n --outQualityMetrics \"$OUTPUT_DIR/fingerprint_metrics.txt\"\n\necho \"=== ChIP-seq QC workflow complete ===\"\necho \"Results are in: $OUTPUT_DIR\"\n",
"context": "Human cell line, H3K4me3 ChIP with one input, paired-end 2x50. Generated with the skill's template, not edited yet.",
"question": "Will this run as-is, and what should I look at in the QC before making tracks?"
}
A trimmed reply (the saved reply on the page is complete; corrected_script is shortened here with a comment line):
{
"task": "review",
"status": "blocked",
"headline": "The template stops at step 2: plotCorrelation needs --whatToPlot, and --whatToShow is not one of its options.",
"experiment_read": "QC of one H3K4me3 ChIP with two replicates against one input, paired-end 2x50 on GRCh38, before any tracks are made. The script assumes coordinate-sorted, indexed BAMs with the template's file names.",
"flag_responses": [
{
"ref": "F1",
"stance": "confirmed",
"note": "deepTools 3.5.6 exits with status 2 at plotCorrelation before reading readCounts.npz, and set -e stops the script there, so steps 3-6 never run. Replace --whatToShow heatmap with --whatToPlot heatmap."
}
],
"findings": [
{
"severity": "low",
"command": 1,
"issue": "multiBamSummary counts duplicate reads while plotCoverage and plotFingerprint ignore them.",
"why": "The correlation and the coverage plots then describe different read sets; the skill removes duplicates in most ChIP-seq analyses.",
"fix": "Add --ignoreDuplicates to multiBamSummary bins, or remove duplicates before this script."
}
],
"qc_checkpoints": [
{
"after": "plotFingerprint (fingerprint.png, fingerprint_metrics.txt)",
"look_for": "How far each ChIP curve bends away from the input curve; the skill runs this before any detailed analysis."
},
{
"after": "plotCorrelation (correlation_heatmap.png)",
"look_for": "Whether the two H3K4me3 replicates are more alike than either is to the input."
},
{
"after": "bamPEFragmentSize (fragmentSizes.png)",
"look_for": "The fragment-length distribution of each library, and any library that differs from the others."
}
],
"corrected_script": "#!/bin/bash\nset -euo pipefail\n\n# deepTools ChIP-seq Quality Control Workflow\n# Generated by deepTools workflow generator\n\n# Configuration\nINPUT_BAM=Input.bam\nCHIP_BAM=(H3K4me3_rep1.bam H3K4me3_rep2.bam)\nGENOME_SIZE=2913022398\nTHREADS=8\nOUTPUT_DIR=chipseq_qc_output\n\n# Create output directory\nmkdir -p \"$OUTPUT_DIR\"\n\necho \"=== Starting ChIP-seq QC workflow ===\"\n\n# Step 1: Correlation analysis\necho \"Step 1: Computing correlation matrix...\"\nmultiBamSummary bins \\\n --bamfiles \"$INPUT_BAM\" \"${CHIP_BAM[@]}\" \\\n -o \"$OUTPUT_DIR/readCounts.npz\" \\\n --ignoreDuplicates \\\n --numberOfProcessors \"$THREADS\"\n\necho \"Step 2: Generating correlation heatmap...\"\nplotCorrelation \\\n -in \"$OUTPUT_DIR/readCounts.npz\" \\\n --corMethod pearson \\\n --whatToPlot heatmap \\\n --plotFile \"$OUTPUT_DIR/correlation_heatmap.png\" \\\n --plotNumbers\n\n# ... steps 3-6 (plotPCA, plotCoverage, bamPEFragmentSize, plotFingerprint) unchanged - trimmed in this example\n",
"to_confirm": [
"The three BAM files have .bai indexes (samtools index), which the skill checks before any deepTools step."
]
}
A source: "tool" flag is what deepTools itself does, so it can only be confirmed, and the status cannot be ready while a high flag stands.
Worked example: plan
An H3K27ac ChIP-seq experiment described in prose. facts carries the assay, layout and assembly the page's selectors set, the effective genome sizes for that assembly, the deepTools 3.5.6 command names and the skill generator's four templates.
{
"task": "plan",
"facts": "{\"deeptools\":\"3.5.6\",\"experiment\":{\"assay\":\"chip\",\"layout\":\"single\",\"assembly\":\"GRCm39\"},\"genome_sizes\":{\"assembly\":\"GRCm39\",\"read_lengths\":[50,75,100,150,200,250],\"deeptools_3_5_6\":{\"nonN\":2654621783,\"by_read_length\":[2309746861,2410055689,2468088461,2495461690,2521902382,2538633971]},\"current_docs\":{\"nonN\":2654621783,\"by_read_length\":[2309746861,2410055689,2468088461,2495461690,2521902382,2538633971]}},\"tools\":[\"bamCoverage\",\"bamCompare\",\"multiBamSummary\",\"multiBigwigSummary\",\"plotCorrelation\",\"plotPCA\",\"plotCoverage\",\"plotFingerprint\",\"bamPEFragmentSize\",\"computeGCBias\",\"correctGCBias\",\"alignmentSieve\",\"computeMatrix\",\"plotHeatmap\",\"plotProfile\",\"plotEnrichment\",\"bigwigCompare\",\"bigwigAverage\",\"computeMatrixOperations\",\"estimateReadFiltering\"],\"workflows\":[{\"id\":\"chipseq_qc\",\"name\":\"ChIP-seq Quality Control\",\"description\":\"Complete QC workflow for ChIP-seq experiments\"},{\"id\":\"chipseq_analysis\",\"name\":\"ChIP-seq Complete Analysis\",\"description\":\"Full ChIP-seq analysis from BAM to heatmaps\"},{\"id\":\"rnaseq_coverage\",\"name\":\"RNA-seq Coverage Tracks\",\"description\":\"Generate strand-specific RNA-seq coverage\"},{\"id\":\"atacseq\",\"name\":\"ATAC-seq Analysis\",\"description\":\"ATAC-seq workflow with Tn5 correction\"}]}",
"experiment": "H3K27ac ChIP-seq in mouse liver, two biological replicates (H3K27ac_rep1.bam, H3K27ac_rep2.bam) and one input (input.bam), single-end 75 bp, aligned to GRCm39 with bowtie2, duplicates not removed yet. I want normalized browser tracks, a check that the ChIP worked, replicate agreement, and heatmaps of signal at promoters (TSS from genes.bed) and at our peaks (H3K27ac_peaks.bed).",
"context": "Linux server with 16 cores."
}
A trimmed reply (script shortened here with a comment line; the real one is the full pipeline):
{
"task": "plan",
"status": "draft",
"headline": "Index, check enrichment and replicate agreement, then RPGC and log2-over-input tracks and heatmaps at TSS and at your peaks.",
"assumptions": [
"BAMs are coordinate-sorted (bowtie2 output sorted by samtools sort).",
"Duplicates are ignored in every deepTools step with --ignoreDuplicates, since they were not removed.",
"genes.bed and H3K27ac_peaks.bed use GRCm39 coordinates, like the BAMs."
],
"steps": [
{
"n": 1,
"tool": "samtools index",
"purpose": "Index the three BAMs.",
"why": "Every deepTools BAM command needs the .bai index."
},
{
"n": 2,
"tool": "plotFingerprint",
"purpose": "Check that the ChIP worked.",
"why": "The skill runs plotFingerprint before detailed analysis."
},
{
"n": 3,
"tool": "multiBamSummary",
"purpose": "Count reads in genome-wide bins for all three BAMs.",
"why": "Input for plotCorrelation."
},
{
"n": 4,
"tool": "plotCorrelation",
"purpose": "Replicate agreement as a Spearman heatmap.",
"why": "--whatToPlot heatmap is required in deepTools 3.5.6."
},
{
"n": 5,
"tool": "bamCoverage",
"purpose": "One RPGC-normalised browser track per replicate.",
"why": "RPGC needs --effectiveGenomeSize; single-end reads need an explicit --extendReads length."
},
{
"n": 6,
"tool": "bamCompare",
"purpose": "log2 ChIP over input per replicate.",
"why": "The skill's ChIP-vs-input comparison uses readCount scaling; bamCompare has no RPGC."
},
{
"n": 7,
"tool": "computeMatrix",
"purpose": "Signal around TSS (genes.bed) and around peak centres (H3K27ac_peaks.bed).",
"why": "reference-point mode, 3 kb either side."
},
{
"n": 8,
"tool": "plotHeatmap",
"purpose": "Heatmaps at promoters and at peaks.",
"why": "What the description asks for."
},
{
"n": 9,
"tool": "plotProfile",
"purpose": "Average profile at peaks.",
"why": "A compact summary of the peak heatmap."
}
],
"normalization": {
"method": "mixed",
"why": "RPGC for the per-replicate bamCoverage tracks (GRCm39 non-N size, since no MAPQ filter is applied); log2 ratio with readCount scaling for bamCompare against the input. The heatmaps use the log2 tracks."
},
"script": "#!/bin/bash\nset -euo pipefail\n# H3K27ac ChIP-seq, mouse liver, single-end 75 bp, GRCm39, duplicates not removed\nCHIP1=\"H3K27ac_rep1.bam\"\nCHIP2=\"H3K27ac_rep2.bam\"\nINPUT=\"input.bam\"\nGENES=\"genes.bed\"\nPEAKS=\"H3K27ac_peaks.bed\"\nGENOME_SIZE=2654621783 # GRCm39 non-N value (multimapping reads kept)\nEXTEND=200 # the skill value for single-end reads; set your fragment length if known\nTHREADS=16\nOUT=\"deeptools_out\"\nmkdir -p \"$OUT\"\n\nsamtools index \"$CHIP1\"\nsamtools index \"$CHIP2\"\nsamtools index \"$INPUT\"\n\nplotFingerprint \\\n --bamfiles \"$CHIP1\" \"$CHIP2\" \"$INPUT\" \\\n --labels rep1 rep2 input \\\n --extendReads \"$EXTEND\" \\\n --ignoreDuplicates \\\n --plotFile \"$OUT/fingerprint.png\" \\\n --outQualityMetrics \"$OUT/fingerprint_metrics.txt\" \\\n --numberOfProcessors \"$THREADS\"\n\nbamCoverage \\\n --bam \"$CHIP1\" \\\n --outFileName \"$OUT/H3K27ac_rep1.RPGC.bw\" \\\n --normalizeUsing RPGC \\\n --effectiveGenomeSize \"$GENOME_SIZE\" \\\n --extendReads \"$EXTEND\" \\\n --ignoreDuplicates \\\n --binSize 10 \\\n --numberOfProcessors \"$THREADS\"\n\nbamCompare \\\n --bamfile1 \"$CHIP1\" \\\n --bamfile2 \"$INPUT\" \\\n --outFileName \"$OUT/H3K27ac_rep1.log2input.bw\" \\\n --scaleFactorsMethod readCount \\\n --operation log2 \\\n --extendReads \"$EXTEND\" \\\n --ignoreDuplicates \\\n --binSize 10 \\\n --numberOfProcessors \"$THREADS\"\n\n# ... the same bamCoverage and bamCompare for rep2, then multiBamSummary bins,\n# plotCorrelation --whatToPlot heatmap, computeMatrix reference-point at TSS and at\n# peak centres, plotHeatmap and plotProfile - trimmed in this example\n",
"qc_checkpoints": [
{
"after": "plotFingerprint",
"look_for": "How far the H3K27ac curves separate from the input curve, before going on."
},
{
"after": "plotCorrelation",
"look_for": "Whether rep1 and rep2 are more alike than either is to the input."
}
],
"to_confirm": [
"The fragment length, if estimated (EXTEND uses the skill's 200 bp for single-end reads).",
"That genes.bed holds one line per gene with strand, so reference-point TSS uses the right end."
]
}
Values the description does not give become shell variables set to TO_FILL and listed in to_confirm; the page's checks treat TO_FILL as unknown rather than wrong.
Input fields
Every field is a string; facts is JSON text.
| field | type | required | meaning |
|---|---|---|---|
task | string | yes | "review" or "plan". |
facts | string | yes | What the page established, as JSON text (below). |
script | string | review | The bash script as checked. The page sends at most its first lines and characters and records the cut in facts.script_sent. |
experiment | string | plan | The experiment in prose: assay, organism and build, samples and file names, read layout and length, what you want to see. |
context | string | no | Notes: the library prep, the aligner, what the tracks are for, the machine - never a token. |
question | string | no | Review lane: a question to answer in the reply. |
retry_note | string | no | Only on a reformat retry. |
The facts string
Both lanes: deeptools ("3.5.6", the version the checks model and the skill pins); experiment (assay = chip, atac, rnaseq, other or ""; layout = paired, single or ""; assembly, e.g. GRCh38, or ""); genome_sizes (for the chosen assembly: read_lengths 50 to 250 and, from the deepTools 3.5.6 table and from the current deepTools docs, nonN and by_read_length; null without an assembly).
Review lane:
{"deeptools": "3.5.6",
"experiment": {"assay": "chip|atac|rnaseq|other|", "layout": "paired|single|", "assembly": "GRCh38|..."},
"genome_sizes": {...} | null,
"status": "blocked|fix_first|ready",
"commands": [{"n": 1, "line": 21, "tool": "multiBamSummary bins",
"parse": "ok or the argparse error", "exits": []}],
"flags": [{"id": "F1", "source": "tool|page", "severity": "high|medium|low", "code": "parse",
"command": 2, "text": "...", "quote": "..."}],
"flags_total": 1,
"generator": {"workflow": "chipseq_qc", "command_line": "..."} | null,
"script_sent": {"lines": 64, "lines_cut": 0, "chars_cut": 0}}
commands lists the deepTools commands in order (line is where each starts in the script;
exits are the tool's own argument checks that would stop it). A tool flag is what
deepTools 3.5.6 does with that command line; a page flag is the page's check, and
quote gives its source in the skill or the deepTools docs. generator is set when the
script is unedited output of the skill's workflow_generator.py.
Plan lane:
{"deeptools": "3.5.6",
"experiment": {"assay": "chip", "layout": "single", "assembly": "GRCm39"},
"genome_sizes": {...} | null,
"tools": ["bamCoverage", "bamCompare", "..."],
"workflows": [{"id": "chipseq_qc", "name": "...", "description": "..."}]}
You can build facts yourself, but the page's browser checks are what make a review
accountable: the model is told not to re-decide what the parser decided, so a flag list you write by
hand is only as good as your own check. Paste the script into the page and use "Download .json" to get
the exact facts it sent. For a script you have checked elsewhere, a minimal facts
is accepted, and the reply then rests on the script text alone:
{"deeptools":"3.5.6","experiment":{"assay":"chip","layout":"paired","assembly":"GRCh38"},"commands":[],"flags":[]}
The output
One JSON object, serialised as a string at data.output.output. Parse it and check that task is the lane you sent.
review:
{"task": "review", "status": "blocked|fix_first|ready", "headline": "", "experiment_read": "",
"flag_responses": [{"ref": "F1", "stance": "confirmed|context|disputed", "note": ""}],
"findings": [{"severity": "high|medium|low", "command": 1, "issue": "", "why": "", "fix": ""}],
"qc_checkpoints": [{"after": "", "look_for": ""}],
"corrected_script": "#!/bin/bash ...", "to_confirm": [""]}
plan:
{"task": "plan", "status": "draft", "headline": "", "assumptions": [""],
"steps": [{"n": 1, "tool": "bamCoverage", "purpose": "", "why": ""}],
"normalization": {"method": "RPGC|CPM|BPM|RPKM|None|log2 ratio (readCount)|mixed", "why": ""},
"script": "#!/bin/bash ...", "qc_checkpoints": [], "to_confirm": []}
stance: confirmed (a real problem here), context (true, but acceptable
for this experiment, with the reason), disputed (the page's check misread the script).
findings[].command is a command number from facts.commands, or null for the
whole script. Scripts in a reply start with #!/bin/bash and set -euo pipefail and
use only deepTools 3.5.6 commands and options (plus samtools index, mkdir and
echo).
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses
the same envelope, so one helper covers the whole API:
{"ok": true, "data": {"job_id": "job_...", "status": "queued"}}
{"ok": false, "error": {"code": "payment_required", "message": "..."}}
The token is minted for this app (the guest endpoint takes {"slug":"deeptools-desk"} in
its body), so no slug header is needed afterwards. Send it as Authorization: Bearer ....
The input object IS the request body. There is no {"input": ...}
wrapper. A wrapped body is answered with an unknown field 'input' warning, and the
model never sees your text.
Error codes
| status | code | what to do |
|---|---|---|
| 400 | validation_error | A field is missing or the wrong type. Every field is a string: facts must be a JSON-encoded string, not an object. |
| 401 | unauthorized | The token is missing, malformed or expired. Get a new one from the token page. |
| 402 | payment_required | The balance is below min_credits. Call /estimate first and top up. |
| 403 | forbidden | The token is valid but not for this app, or a guest token tried a metered run. A guest cannot run; sign in for a personal token. |
| 404 | not_found | Unknown job id, or the app slug does not exist. |
| 409 | conflict | The same Idempotency-Key was replayed with a different body. Change the key or send the original input. |
| 429 | rate_limited | Too many requests. Back off and retry; do not tight-loop. |
| 5xx | internal | A server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice. |
1. A tiny client
One helper that sends the token, unwraps data and raises on ok: false.
The token comes from the token page (Copy token or
Copy shell export); step 2 covers the kinds of token and minting one from code.
In every language the steps below continue the same script: Go and Java put steps 2 to 7 inside
main, in order.
# Every call is the same three things: the base URL, your bearer token,
# and a JSON body. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="deeptools-desk"
TOKEN="${SKILLSAFE_TOKEN:-YOUR_TOKEN}" # from https://deeptools-desk.skillsafe.ai/tokens.html
call() { # call <path> [json-body]
if [ -n "$2" ]; then
curl -sS -X POST "$BASE/$1" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$2"
else
curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN"
fi
}
import json, os, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "deeptools-desk"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://deeptools-desk.skillsafe.ai/tokens.html
def call(path, body=None, key=None):
"""Returns the unwrapped `data`, or raises with the API error code.
`key`, when given, is sent as the Idempotency-Key header."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"{BASE}/{path}", data=data, method="POST" if body is not None else "GET")
req.add_header("Authorization", f"Bearer {TOKEN}")
if body is not None:
req.add_header("Content-Type", "application/json")
if key:
req.add_header("Idempotency-Key", key)
try:
with urllib.request.urlopen(req) as r:
payload = json.load(r)
except urllib.error.HTTPError as e:
payload = json.load(e)
if not payload.get("ok"):
err = payload.get("error", {})
raise RuntimeError(f"{err.get('code')}: {err.get('message')}")
return payload["data"]
// Node 18+, saved as an ES module (client.mjs) so top-level await works.
import { readFileSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "deeptools-desk";
// Paste the token from https://deeptools-desk.skillsafe.ai/tokens.html into a file named "token",
// or replace the fallback with it.
let TOKEN = "YOUR_TOKEN";
try { TOKEN = readFileSync("token", "utf8").trim(); } catch {}
// Returns the unwrapped `data`, or throws with the API error code.
// `key`, when given, is sent as the Idempotency-Key header.
async function call(path, body, key) {
const headers = { Authorization: `Bearer ${TOKEN}` };
if (body) headers["Content-Type"] = "application/json";
if (key) headers["Idempotency-Key"] = key;
const res = await fetch(`${BASE}/${path}`, {
method: body ? "POST" : "GET",
headers,
body: body ? JSON.stringify(body) : undefined,
});
const payload = await res.json();
if (!payload.ok) throw new Error(`${payload.error.code}: ${payload.error.message}`);
return payload.data;
}
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const (
base = "https://api.skillsafe.ai/v1/app-api"
slug = "deeptools-desk"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://deeptools-desk.skillsafe.ai/tokens.html
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// call returns the unwrapped data. A non-empty key is sent as the Idempotency-Key header.
func call(path string, body any, key string) (json.RawMessage, error) {
method := http.MethodGet
var rdr io.Reader
if body != nil {
method = http.MethodPost
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequest(method, base+"/"+path, rdr)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if key != "" {
req.Header.Set("Idempotency-Key", key)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
// must turns a call result into a map, or stops the program.
func must(raw json.RawMessage, err error) map[string]any {
if err != nil {
panic(err)
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
panic(err)
}
return m
}
func main() {
// steps 2 to 7 go here, in order
}
// Java 17+, with Jackson (com.fasterxml.jackson.core:jackson-databind) for JSON:
// the JDK has no JSON parser of its own.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.Iterator;
import java.util.Map;
public class DeeptoolsDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "deeptools-desk";
static String TOKEN = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static final ObjectMapper JSON = new ObjectMapper();
// Returns the unwrapped data, or throws with the API error code. key may be null.
static JsonNode call(String path, String jsonBody, String key) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.header("Authorization", "Bearer " + TOKEN);
if (key != null) b.header("Idempotency-Key", key);
if (jsonBody != null) {
b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
} else {
b.GET();
}
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
JsonNode env = JSON.readTree(res.body());
if (!env.path("ok").asBoolean()) {
JsonNode e = env.path("error");
throw new RuntimeException(e.path("code").asText() + ": " + e.path("message").asText());
}
return env.path("data");
}
public static void main(String[] args) throws Exception {
// steps 2 to 7 go here, in order
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "deeptools-desk"
$token = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://deeptools-desk.skillsafe.ai/tokens.html
# Returns the unwrapped data, or raises with the API error code.
# key, when given, is sent as the Idempotency-Key header.
def call(path, body = nil, key = nil)
uri = URI("#{BASE}/#{path}")
req = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{$token}"
req["Idempotency-Key"] = key if key
if body
req["Content-Type"] = "application/json"
req.body = body.to_json
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise "#{payload['error']['code']}: #{payload['error']['message']}" unless payload["ok"]
payload["data"]
end
<?php
// PHP 8+. The later steps continue this file.
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "deeptools-desk";
$GLOBALS["token"] = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"; // from /tokens.html
// Returns the unwrapped data, or throws with the API error code.
// $key, when given, is sent as the Idempotency-Key header.
function call(string $path, ?array $body = null, ?string $key = null) {
$ch = curl_init(BASE . "/" . $path);
$headers = ["Authorization: Bearer " . $GLOBALS["token"]];
if ($key !== null) {
$headers[] = "Idempotency-Key: " . $key;
}
if ($body !== null) {
$headers[] = "Content-Type: application/json";
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
// Program.cs (.NET 6+, top-level statements). The later steps continue this file.
using System;
using System.IO;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "deeptools-desk";
var token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
var http = new HttpClient();
// Returns the unwrapped data, or throws with the API error code.
// json is the request body as JSON text; key, when given, is the Idempotency-Key.
async Task<JsonElement> Call(string path, string? json = null, string? key = null)
{
var req = new HttpRequestMessage(json == null ? HttpMethod.Get : HttpMethod.Post, $"{Base}/{path}");
req.Headers.Add("Authorization", $"Bearer {token}");
if (key != null) req.Headers.Add("Idempotency-Key", key);
if (json != null) req.Content = new StringContent(json, Encoding.UTF8, "application/json");
using var res = await http.SendAsync(req);
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
var payload = doc.RootElement;
if (!payload.GetProperty("ok").GetBoolean())
{
var e = payload.GetProperty("error");
throw new Exception($"{e.GetProperty("code")}: {e.GetProperty("message")}");
}
return payload.GetProperty("data").Clone();
}
2. Get a token
The easiest route is the token page: it shows the token this browser
already holds, with Copy token and Copy shell export buttons, and
a sign-in button for a personal token. A guest token, minted with
POST /guest and {"slug":"deeptools-desk"}, can call /me and
/estimate; both lanes are metered, so /run and /run-stream need
a personal token.
# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
# https://deeptools-desk.skillsafe.ai/tokens.html
# export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead. A guest token is enough
# for /me and /estimate; a run needs a personal token from signing in.
curl -sS -X POST "$BASE/guest" \
-H "Content-Type: application/json" -d '{"slug":"deeptools-desk"}'
# {"ok":true,"data":{"token":"...","subject_type":"guest"}}
# Open https://deeptools-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot start a metered run.
if TOKEN == "YOUR_TOKEN":
req = urllib.request.Request(f"{BASE}/guest", data=json.dumps({"slug": SLUG}).encode(), method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
// Open https://deeptools-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
if (TOKEN === "YOUR_TOKEN") {
const guestRes = await fetch(`${BASE}/guest`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: SLUG }),
});
TOKEN = (await guestRes.json()).data.token;
}
// Open https://deeptools-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
if token == "" {
gres, err := http.Post(base+"/guest", "application/json",
strings.NewReader(`{"slug":"`+slug+`"}`))
if err != nil {
panic(err)
}
var guest struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
_ = json.NewDecoder(gres.Body).Decode(&guest)
gres.Body.Close()
token = guest.Data.Token
}
// Open https://deeptools-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
if (TOKEN.equals("YOUR_TOKEN")) {
HttpRequest guestReq = HttpRequest.newBuilder(URI.create(BASE + "/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"" + SLUG + "\"}"))
.build();
String guest = HTTP.send(guestReq, HttpResponse.BodyHandlers.ofString()).body();
TOKEN = JSON.readTree(guest).path("data").path("token").asText();
}
# Open https://deeptools-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot start a metered run.
if $token == "YOUR_TOKEN"
uri = URI("#{BASE}/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = { slug: SLUG }.to_json
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
$token = JSON.parse(res.body)["data"]["token"]
end
// Open https://deeptools-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
if ($GLOBALS["token"] === "YOUR_TOKEN") {
$ch = curl_init(BASE . "/guest");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => SLUG]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$guest = json_decode(curl_exec($ch), true);
curl_close($ch);
$GLOBALS["token"] = $guest["data"]["token"];
}
// Open https://deeptools-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
if (token == "YOUR_TOKEN")
{
var guestReq = new HttpRequestMessage(HttpMethod.Post, $"{Base}/guest");
guestReq.Content = new StringContent("{\"slug\":\"" + Slug + "\"}", Encoding.UTF8, "application/json");
using var guestRes = await http.SendAsync(guestReq);
using var guestDoc = JsonDocument.Parse(await guestRes.Content.ReadAsStringAsync());
token = guestDoc.RootElement.GetProperty("data").GetProperty("token").GetString()!;
}
3. Check the session and the balance
call me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
print(me["subject_type"], me.get("credits"))
const me = await call("me");
console.log(me.subject_type, me.credits);
me := must(call("me", nil, ""))
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = call("me", null, null);
System.out.println(me.path("subject_type").asText() + " " + me.path("credits"));
me = call("me")
puts "#{me['subject_type']} #{me['credits']}"
$me = call("me");
echo $me["subject_type"], " ", $me["credits"] ?? "-", PHP_EOL;
var me = await Call("me");
var credits = me.TryGetProperty("credits", out var c) ? c.ToString() : "-";
Console.WriteLine($"{me.GetProperty("subject_type").GetString()} {credits}");
4. Price the run (free)
/estimate returns the model binding and the credits a run would reserve. It creates no
job and charges nothing. Expect model_alias gpt-terra.
hold_credits is a reservation, not the price. min_credits is
the least balance that can start a run. What you pay is charged_credits, reported on the
finished job, usually far lower. The body is the input object itself, with no
{"input": ...} wrapper. /estimate does no input validation,
so check the shape yourself: an object whose every value is a string, task equal to
review or plan, a facts that is a JSON string parsing to an object,
and a non-empty script (review) or experiment (plan). Save one of the worked
examples above as body.json to try it.
# body.json is the input object itself - no {"input": ...} wrapper. estimate does
# not validate it, so check the shape first:
python3 -c '
import json
b = json.load(open("body.json"))
assert isinstance(b, dict) and b.get("task") in ("review", "plan"), "task must be review or plan"
assert all(isinstance(v, str) for v in b.values()), "every field is a string"
assert isinstance(json.loads(b["facts"]), dict), "facts is a JSON string of an object"
need = "script" if b["task"] == "review" else "experiment"
assert b.get(need, "").strip(), b["task"] + " needs " + need
'
INPUT=$(cat body.json)
call estimate "$INPUT"
# {"ok":true,"data":{"model":"...","model_alias":"gpt-terra",
# "markup_bps":...,"hold_credits":...,"min_credits":...,"sponsor_enabled":false}}
# hold_credits is RESERVED, not the price; charged_credits after the run is the cost.
with open("body.json") as fh:
INPUT = json.load(fh) # the input object itself, no wrapper
assert isinstance(INPUT, dict) and INPUT.get("task") in ("review", "plan"), "task must be review or plan"
assert all(isinstance(v, str) for v in INPUT.values()), "every field is a string"
assert isinstance(json.loads(INPUT["facts"]), dict), "facts is a JSON string of an object"
need = "script" if INPUT["task"] == "review" else "experiment"
assert INPUT.get(need, "").strip(), f"{INPUT['task']} needs {need}"
est = call("estimate", INPUT)
print(est["model_alias"], "reserves", est["hold_credits"], "credits (not the price)")
const INPUT = JSON.parse(readFileSync("body.json", "utf8")); // no {input: ...} wrapper
if (!INPUT || typeof INPUT !== "object" || !["review", "plan"].includes(INPUT.task)) throw new Error("task must be review or plan");
if (!Object.values(INPUT).every((v) => typeof v === "string")) throw new Error("every field is a string");
const facts = JSON.parse(INPUT.facts);
if (!facts || typeof facts !== "object" || Array.isArray(facts)) throw new Error("facts is a JSON string of an object");
const need = INPUT.task === "review" ? "script" : "experiment";
if (!(INPUT[need] || "").trim()) throw new Error(`${INPUT.task} needs ${need}`);
const est = await call("estimate", INPUT);
console.log(est.model_alias, "reserves", est.hold_credits, "credits (not the price)");
raw, err := os.ReadFile("body.json")
if err != nil {
panic(err)
}
var input map[string]string // every field is a string; Unmarshal fails otherwise
if err := json.Unmarshal(raw, &input); err != nil {
panic("body.json must be an object of strings: " + err.Error())
}
lane := input["task"]
if lane != "review" && lane != "plan" {
panic("task must be review or plan")
}
var facts map[string]any
if err := json.Unmarshal([]byte(input["facts"]), &facts); err != nil || facts == nil {
panic("facts must be a JSON string of an object")
}
need := "experiment"
if lane == "review" {
need = "script"
}
if strings.TrimSpace(input[need]) == "" {
panic(lane + " needs " + need)
}
est := must(call("estimate", input, ""))
fmt.Println(est["model_alias"], "reserves", est["hold_credits"], "credits (not the price)")
String input = Files.readString(Path.of("body.json")); // the input object itself, sent as is
JsonNode in = JSON.readTree(input);
String lane = in.path("task").asText();
if (!in.isObject() || !(lane.equals("review") || lane.equals("plan")))
throw new IllegalStateException("task must be review or plan");
for (Iterator<Map.Entry<String, JsonNode>> it = in.fields(); it.hasNext(); )
if (!it.next().getValue().isTextual()) throw new IllegalStateException("every field is a string");
if (!JSON.readTree(in.path("facts").asText()).isObject())
throw new IllegalStateException("facts is a JSON string of an object");
String need = lane.equals("review") ? "script" : "experiment";
if (in.path(need).asText().isBlank()) throw new IllegalStateException(lane + " needs " + need);
JsonNode est = call("estimate", input, null);
System.out.println(est.path("model_alias").asText() + " reserves " + est.path("hold_credits")
+ " credits (not the price)");
INPUT = JSON.parse(File.read("body.json")) # no {"input": ...} wrapper
raise "task must be review or plan" unless INPUT.is_a?(Hash) && %w[review plan].include?(INPUT["task"])
raise "every field is a string" unless INPUT.values.all? { |v| v.is_a?(String) }
raise "facts is a JSON string of an object" unless JSON.parse(INPUT["facts"]).is_a?(Hash)
need = INPUT["task"] == "review" ? "script" : "experiment"
raise "#{INPUT['task']} needs #{need}" if INPUT[need].to_s.strip.empty?
est = call("estimate", INPUT)
puts "#{est['model_alias']} reserves #{est['hold_credits']} credits (not the price)"
$input = json_decode(file_get_contents("body.json"), true); // no {"input": ...} wrapper
if (!is_array($input) || !in_array($input["task"] ?? "", ["review", "plan"], true)) { throw new Exception("task must be review or plan"); }
foreach ($input as $v) { if (!is_string($v)) { throw new Exception("every field is a string"); } }
if (!is_object(json_decode($input["facts"] ?? ""))) { throw new Exception("facts is a JSON string of an object"); }
$need = $input["task"] === "review" ? "script" : "experiment";
if (trim($input[$need] ?? "") === "") { throw new Exception($input["task"] . " needs " . $need); }
$est = call("estimate", $input);
echo $est["model_alias"], " reserves ", $est["hold_credits"], " credits (not the price)\n";
var input = File.ReadAllText("body.json"); // the input object itself, sent as is
using var inputDoc = JsonDocument.Parse(input);
var root = inputDoc.RootElement;
if (root.ValueKind != JsonValueKind.Object) throw new Exception("body.json must be an object");
foreach (var p in root.EnumerateObject())
if (p.Value.ValueKind != JsonValueKind.String) throw new Exception("every field is a string");
var lane = root.TryGetProperty("task", out var t) ? t.GetString() : null;
if (lane != "review" && lane != "plan") throw new Exception("task must be review or plan");
using (var factsDoc = JsonDocument.Parse(root.GetProperty("facts").GetString()!))
if (factsDoc.RootElement.ValueKind != JsonValueKind.Object) throw new Exception("facts is a JSON string of an object");
var need = lane == "review" ? "script" : "experiment";
if (!root.TryGetProperty(need, out var nv) || string.IsNullOrWhiteSpace(nv.GetString()))
throw new Exception($"{lane} needs {need}");
var est = await Call("estimate", input);
Console.WriteLine($"{est.GetProperty("model_alias")} reserves {est.GetProperty("hold_credits")} credits (not the price)");
5. Run it, then poll
POST /run returns a job_id; poll GET /jobs/{id} until it is
terminal. The reply is a string at data.output.output: parse it (step 7). Send an
Idempotency-Key built from the lane, a hash of the input and the attempt number,
deeptools-desk:<lane>:<hash>:a<attempt>, so a retried request returns the
same job instead of billing a second run. Use one key per distinct input: an edited script, facts,
experiment, context or question is a new hash, and replaying an old key with a different body is a
409. Any stable digest of the body works. Leave retry_note out of the hash and
bump the attempt instead.
# Always send an Idempotency-Key derived from the input. A retried request with
# the same key returns the SAME job instead of billing a second run.
LANE=$(printf '%s' "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["task"])') # review or plan
KEY="deeptools-desk:$LANE:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"
JOB=$(curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
while :; do
OUT=$(call "jobs/$JOB")
STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
[ "$STATUS" = "succeeded" ] && break
[ "$STATUS" = "failed" ] && echo "$OUT" && exit 1
sleep 2
done
# {"ok":true,"data":{"job_id":"job_...","status":"succeeded",
# "output":{"output":"{\"task\":\"review\",\"status\":\"blocked\",\"headline\":\"...\", ...}"},
# "charged_credits":...,"truncated":false}}
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])' > reply.json
import hashlib, time
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"deeptools-desk:{INPUT['task']}:{digest}:a1"
job_id = call("run", INPUT, key)["job_id"]
while True:
job = call(f"jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
break
time.sleep(2)
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
text = job["output"]["output"] # the reply, as a string
print("charged", job.get("charged_credits"), "truncated", job.get("truncated"))
const digest = createHash("sha256").update(JSON.stringify(INPUT)).digest("hex").slice(0, 16);
const key = `deeptools-desk:${INPUT.task}:${digest}:a1`;
const jobId = (await call("run", INPUT, key)).job_id;
let job;
do {
if (job) await new Promise((r) => setTimeout(r, 2000));
job = await call(`jobs/${jobId}`);
} while (job.status !== "succeeded" && job.status !== "failed");
if (job.status === "failed") throw new Error(JSON.stringify(job.error));
const text = job.output.output; // the reply, as a string
console.log("charged", job.charged_credits, "truncated", job.truncated);
body, _ := json.Marshal(input) // Go sorts map keys, so this digest is stable
sum := sha256.Sum256(body)
key := fmt.Sprintf("deeptools-desk:%s:%x:a1", lane, sum[:8])
jobID := must(call("run", input, key))["job_id"].(string)
var job map[string]any
for {
job = must(call("jobs/"+jobID, nil, ""))
if job["status"] == "succeeded" || job["status"] == "failed" {
break
}
time.Sleep(2 * time.Second)
}
if job["status"] == "failed" {
panic(fmt.Sprint(job["error"]))
}
text := job["output"].(map[string]any)["output"].(string) // the reply, as a string
fmt.Println("charged", job["charged_credits"], "truncated", job["truncated"])
String hash = HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-256").digest(input.getBytes(StandardCharsets.UTF_8))).substring(0, 16);
String key = "deeptools-desk:" + lane + ":" + hash + ":a1";
String jobId = call("run", input, key).path("job_id").asText();
JsonNode job;
while (true) {
job = call("jobs/" + jobId, null, null);
String status = job.path("status").asText();
if (status.equals("succeeded")) break;
if (status.equals("failed")) throw new RuntimeException(job.toString());
Thread.sleep(2000);
}
String text = job.path("output").path("output").asText(); // the reply, as a string
System.out.println("charged " + job.path("charged_credits") + " truncated " + job.path("truncated"));
require "digest"
key = "deeptools-desk:#{INPUT['task']}:#{Digest::SHA256.hexdigest(INPUT.to_json)[0, 16]}:a1"
job_id = call("run", INPUT, key)["job_id"]
job = nil
loop do
job = call("jobs/#{job_id}")
break if %w[succeeded failed].include?(job["status"])
sleep 2
end
raise job.inspect if job["status"] == "failed"
text = job["output"]["output"] # the reply, as a string
puts "charged #{job['charged_credits']} truncated #{job['truncated']}"
$key = "deeptools-desk:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$jobId = call("run", $input, $key)["job_id"];
while (true) {
$job = call("jobs/" . $jobId);
if (in_array($job["status"], ["succeeded", "failed"], true)) { break; }
sleep(2);
}
if ($job["status"] === "failed") { throw new RuntimeException(json_encode($job)); }
$text = $job["output"]["output"]; // the reply, as a string
echo "charged ", $job["charged_credits"] ?? "?", PHP_EOL;
var hash = BitConverter.ToString(SHA256.Create().ComputeHash(Encoding.UTF8.GetBytes(input)))
.Replace("-", "").ToLowerInvariant()[..16];
var key = $"deeptools-desk:{lane}:{hash}:a1";
var started = await Call("run", input, key);
var jobId = started.GetProperty("job_id").GetString();
JsonElement job;
while (true)
{
job = await Call($"jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status == "succeeded") break;
if (status == "failed") throw new Exception(job.ToString());
await Task.Delay(2000);
}
var text = job.GetProperty("output").GetProperty("output").GetString()!; // the reply, as a string
Console.WriteLine($"charged {job.GetProperty("charged_credits")}");
6. Or stream it
POST /run-stream takes the same body and headers as /run (use it instead of
step 5, not after it with the same key; build the key as step 5 does) and answers with server-sent events:
job (the job id), delta (chunks of the reply) and done (the
status, charged_credits, truncated and, when present, the full
output). A browser page may receive only tick heartbeats and then
done, never a delta, so take the reply from done.output.output
when it is there, fall back to the concatenated deltas, and fall back again to
GET /jobs/{id}.
# Server-sent events. `delta` events carry chunks of the reply; `done` carries the
# status, charged_credits and the truncated flag. Ignore `tick` heartbeats.
curl -N -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-H "Accept: text/event-stream" \
-d "$INPUT"
# event: job {"job_id":"job_..."}
# event: delta {"text":"{\"task\":\"review\",\"status\":\"blocked\",\"headline\":\"The"}
# event: done {"status":"succeeded","charged_credits":...,"truncated":false}
req = urllib.request.Request(f"{BASE}/run-stream", data=json.dumps(INPUT).encode(), method="POST")
for h, v in (("Authorization", f"Bearer {TOKEN}"), ("Content-Type", "application/json"),
("Idempotency-Key", key), ("Accept", "text/event-stream")):
req.add_header(h, v)
deltas, done, event = "", {}, None
with urllib.request.urlopen(req) as stream:
for line in stream:
line = line.decode().rstrip("\r\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: ") and event == "delta":
deltas += json.loads(line[6:]).get("text", "")
elif line.startswith("data: ") and event == "done":
done = json.loads(line[6:])
streamed = (done.get("output") or {}).get("output") or deltas # browsers may get only ticks + done
print(done.get("status"), done.get("charged_credits"), done.get("truncated"))
const sres = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key, Accept: "text/event-stream" },
body: JSON.stringify(INPUT),
});
const reader = sres.body.getReader();
const dec = new TextDecoder();
let buf = "", deltas = "", event = null, doneEvent = null;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i).replace(/\r$/, "");
buf = buf.slice(i + 1);
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ") && event === "delta") deltas += JSON.parse(line.slice(6)).text || "";
else if (line.startsWith("data: ") && event === "done") doneEvent = JSON.parse(line.slice(6));
}
}
const streamed = doneEvent?.output?.output || deltas; // browsers may get only ticks + done
console.log(doneEvent?.status, streamed.length);
sreq, _ := http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(body))
sreq.Header.Set("Authorization", "Bearer "+token)
sreq.Header.Set("Content-Type", "application/json")
sreq.Header.Set("Idempotency-Key", key)
sreq.Header.Set("Accept", "text/event-stream")
sres, err := http.DefaultClient.Do(sreq)
if err != nil {
panic(err)
}
defer sres.Body.Close()
var deltas strings.Builder
event, streamed := "", ""
sc := bufio.NewScanner(sres.Body)
sc.Buffer(make([]byte, 1<<20), 4<<20)
for sc.Scan() {
line := strings.TrimRight(sc.Text(), "\r")
switch {
case strings.HasPrefix(line, "event: "):
event = line[7:]
case strings.HasPrefix(line, "data: ") && event == "delta":
var d struct {
Text string `json:"text"`
}
_ = json.Unmarshal([]byte(line[6:]), &d)
deltas.WriteString(d.Text)
case strings.HasPrefix(line, "data: ") && event == "done":
var d struct {
Status string `json:"status"`
Output struct {
Output string `json:"output"`
} `json:"output"`
}
_ = json.Unmarshal([]byte(line[6:]), &d)
streamed = d.Output.Output
fmt.Println("done:", d.Status)
}
}
if streamed == "" {
streamed = deltas.String() // browsers may get only ticks + done
}
fmt.Println(len(streamed), "characters streamed")
HttpRequest stream = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
StringBuilder deltas = new StringBuilder();
String[] event = {""};
String[] fromDone = {""};
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
if (line.startsWith("event: ")) {
event[0] = line.substring(7);
} else if (line.startsWith("data: ")) {
try {
JsonNode d = JSON.readTree(line.substring(6));
if (event[0].equals("delta")) deltas.append(d.path("text").asText());
if (event[0].equals("done")) fromDone[0] = d.path("output").path("output").asText();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
});
String streamed = fromDone[0].isEmpty() ? deltas.toString() : fromDone[0]; // browsers may get only ticks + done
System.out.println(streamed.length() + " characters streamed");
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
{ "Authorization" => "Bearer #{$token}", "Content-Type" => "application/json",
"Idempotency-Key" => key, "Accept" => "text/event-stream" }.each { |k, v| req[k] = v }
req.body = INPUT.to_json
buf, deltas, event, streamed = +"", +"", nil, nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |h|
h.request(req) do |res|
res.read_body do |chunk|
buf << chunk
while (i = buf.index("\n"))
line = buf.slice!(0, i + 1).chomp
if line.start_with?("event: ")
event = line[7..-1]
elsif line.start_with?("data: ") && event == "delta"
deltas << JSON.parse(line[6..-1])["text"].to_s
elsif line.start_with?("data: ") && event == "done"
streamed = (JSON.parse(line[6..-1])["output"] || {})["output"]
end
end
end
end
end
streamed = deltas if streamed.to_s.empty? # browsers may get only ticks + done
puts "#{streamed.length} characters streamed"
$buf = ""; $deltas = ""; $event = null; $streamed = "";
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . $GLOBALS["token"], "Content-Type: application/json",
"Idempotency-Key: " . $key, "Accept: text/event-stream"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$buf, &$deltas, &$event, &$streamed) {
$buf .= $chunk;
while (($i = strpos($buf, "\n")) !== false) {
$line = rtrim(substr($buf, 0, $i), "\r");
$buf = substr($buf, $i + 1);
if (str_starts_with($line, "event: ")) {
$event = substr($line, 7);
} elseif (str_starts_with($line, "data: ") && $event === "delta") {
$deltas .= json_decode(substr($line, 6), true)["text"] ?? "";
} elseif (str_starts_with($line, "data: ") && $event === "done") {
$streamed = json_decode(substr($line, 6), true)["output"]["output"] ?? "";
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
if ($streamed === "") { $streamed = $deltas; } // browsers may get only ticks + done
echo strlen($streamed), " characters streamed\n";
var sreq = new HttpRequestMessage(HttpMethod.Post, $"{Base}/run-stream");
sreq.Headers.Add("Authorization", $"Bearer {token}");
sreq.Headers.Add("Idempotency-Key", key);
sreq.Headers.Add("Accept", "text/event-stream");
sreq.Content = new StringContent(input, Encoding.UTF8, "application/json");
using var sres = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await sres.Content.ReadAsStreamAsync());
var deltas = new StringBuilder();
string? ev = null, line, fromDone = null;
while ((line = await sr.ReadLineAsync()) != null)
{
if (line.StartsWith("event: ")) ev = line[7..];
else if (line.StartsWith("data: ") && ev == "delta")
{
using var d = JsonDocument.Parse(line[6..]);
if (d.RootElement.TryGetProperty("text", out var tx)) deltas.Append(tx.GetString());
}
else if (line.StartsWith("data: ") && ev == "done")
{
using var d = JsonDocument.Parse(line[6..]);
if (d.RootElement.TryGetProperty("output", out var o) && o.ValueKind == JsonValueKind.Object
&& o.TryGetProperty("output", out var oo)) fromDone = oo.GetString();
}
}
var streamed = string.IsNullOrEmpty(fromDone) ? deltas.ToString() : fromDone; // browsers may get only ticks + done
Console.WriteLine($"{streamed.Length} characters streamed");
7. Parse the reply, and re-check the script
The reply is one JSON object serialised as a string (text from step 5, or
streamed from step 6). Parse it and check that task is the lane you asked for.
Then check the script it returns (corrected_script for a review, script for a
plan) before you run it: paste it into the page, which parses it with the same deepTools 3.5.6 checks,
and resolve every TO_FILL it holds.
# The reply is a JSON string inside data.output.output (saved as reply.json in step 5):
python3 -c '
import json
r = json.load(open("reply.json"))
print(r["task"], r["status"], r["headline"])
if r["task"] == "review":
for f in r["flag_responses"]: print(f["ref"], f["stance"], f["note"])
for f in r["findings"]: print("-", f["severity"], f["command"], f["issue"], "->", f["fix"])
if r["corrected_script"]: open("corrected.sh", "w").write(r["corrected_script"])
else:
for s in r["steps"]: print(s["n"], s["tool"], s["purpose"])
open("plan.sh", "w").write(r["script"])
'
# Shell syntax only; the deepTools options are checked by pasting the script into the page.
[ -f corrected.sh ] && bash -n corrected.sh
[ -f plan.sh ] && bash -n plan.sh && grep -n TO_FILL plan.sh
reply = json.loads(text) # or `streamed` from step 6
assert reply["task"] == INPUT["task"], "the model answered as another lane"
print(reply["status"], reply["headline"])
if reply["task"] == "review":
for r in reply["flag_responses"]:
print(r["ref"], r["stance"], r["note"])
for f in reply["findings"]:
print("-", f["severity"], f["command"], f["issue"], "->", f["fix"])
if reply["corrected_script"]:
with open("corrected.sh", "w") as fh:
fh.write(reply["corrected_script"])
else:
for s in reply["steps"]:
print(s["n"], s["tool"], s["purpose"])
with open("plan.sh", "w") as fh:
fh.write(reply["script"])
print(reply["normalization"]["method"], "-", reply["normalization"]["why"])
const reply = JSON.parse(text); // or `streamed` from step 6
if (reply.task !== INPUT.task) throw new Error("the model answered as another lane");
console.log(reply.status, reply.headline);
if (reply.task === "review") {
for (const r of reply.flag_responses) console.log(r.ref, r.stance, r.note);
for (const f of reply.findings) console.log("-", f.severity, f.command, f.issue, "->", f.fix);
if (reply.corrected_script) writeFileSync("corrected.sh", reply.corrected_script);
} else {
for (const s of reply.steps) console.log(s.n, s.tool, s.purpose);
writeFileSync("plan.sh", reply.script);
console.log(reply.normalization.method, "-", reply.normalization.why);
}
var reply map[string]any
if err := json.Unmarshal([]byte(text), &reply); err != nil { // or streamed from step 6
panic("the reply is not one JSON object: " + err.Error())
}
if reply["task"] != lane {
panic("the model answered as another lane")
}
fmt.Println(reply["status"], reply["headline"])
if lane == "review" {
for _, x := range reply["flag_responses"].([]any) {
r := x.(map[string]any)
fmt.Println(r["ref"], r["stance"], r["note"])
}
for _, x := range reply["findings"].([]any) {
f := x.(map[string]any)
fmt.Println("-", f["severity"], f["command"], f["issue"], "->", f["fix"])
}
if s, _ := reply["corrected_script"].(string); s != "" {
if err := os.WriteFile("corrected.sh", []byte(s), 0o644); err != nil {
panic(err)
}
}
} else {
s, _ := reply["script"].(string)
if err := os.WriteFile("plan.sh", []byte(s), 0o644); err != nil {
panic(err)
}
}
JsonNode reply = JSON.readTree(text); // or streamed from step 6
if (!reply.path("task").asText().equals(lane))
throw new IllegalStateException("the model answered as another lane");
System.out.println(reply.path("status").asText() + " " + reply.path("headline").asText());
if (lane.equals("review")) {
for (JsonNode r : reply.path("flag_responses"))
System.out.println(r.path("ref").asText() + " " + r.path("stance").asText() + " " + r.path("note").asText());
for (JsonNode f : reply.path("findings"))
System.out.println("- " + f.path("severity").asText() + " " + f.path("command") + " "
+ f.path("issue").asText() + " -> " + f.path("fix").asText());
String fixedScript = reply.path("corrected_script").asText();
if (!fixedScript.isEmpty()) Files.writeString(Path.of("corrected.sh"), fixedScript);
} else {
Files.writeString(Path.of("plan.sh"), reply.path("script").asText());
}
reply = JSON.parse(text) # or streamed from step 6
raise "the model answered as another lane" unless reply["task"] == INPUT["task"]
puts reply["status"], reply["headline"]
if reply["task"] == "review"
reply["flag_responses"].each { |r| puts "#{r['ref']} #{r['stance']} #{r['note']}" }
reply["findings"].each { |f| puts "- #{f['severity']} #{f['command']} #{f['issue']} -> #{f['fix']}" }
File.write("corrected.sh", reply["corrected_script"]) unless reply["corrected_script"].to_s.empty?
else
reply["steps"].each { |s| puts "#{s['n']} #{s['tool']} #{s['purpose']}" }
File.write("plan.sh", reply["script"])
end
$reply = json_decode($text, true); // or $streamed from step 6
if (!is_array($reply) || ($reply["task"] ?? "") !== $input["task"]) { throw new Exception("the model answered as another lane"); }
echo $reply["status"], " ", $reply["headline"], "\n";
if ($reply["task"] === "review") {
foreach ($reply["flag_responses"] as $r) { echo $r["ref"], " ", $r["stance"], " ", $r["note"], "\n"; }
foreach ($reply["findings"] as $f) { echo "- ", $f["severity"], " ", $f["issue"], " -> ", $f["fix"], "\n"; }
if (($reply["corrected_script"] ?? "") !== "") { file_put_contents("corrected.sh", $reply["corrected_script"]); }
} else {
foreach ($reply["steps"] as $s) { echo $s["n"], " ", $s["tool"], " ", $s["purpose"], "\n"; }
file_put_contents("plan.sh", $reply["script"]);
}
using var replyDoc = JsonDocument.Parse(text); // or streamed from step 6
var reply = replyDoc.RootElement;
if (reply.GetProperty("task").GetString() != lane) throw new Exception("the model answered as another lane");
Console.WriteLine($"{reply.GetProperty("status")} {reply.GetProperty("headline")}");
if (lane == "review")
{
foreach (var r in reply.GetProperty("flag_responses").EnumerateArray())
Console.WriteLine($"{r.GetProperty("ref")} {r.GetProperty("stance")} {r.GetProperty("note")}");
foreach (var f in reply.GetProperty("findings").EnumerateArray())
Console.WriteLine($"- {f.GetProperty("severity")} {f.GetProperty("issue")} -> {f.GetProperty("fix")}");
var fixedScript = reply.GetProperty("corrected_script").GetString();
if (!string.IsNullOrEmpty(fixedScript)) File.WriteAllText("corrected.sh", fixedScript);
}
else
{
File.WriteAllText("plan.sh", reply.GetProperty("script").GetString());
}
Costs
- The checks are free and run in the page: the generator port, the deepTools 3.5.6 argument checks, the best-practice checks, the effective genome sizes and the BED check. Nothing is metered until you start a lane.
/estimateis free. It creates no job and returnshold_credits: a reservation held against your balance while the run executes, not the price.- A run is billed only for what it uses:
charged_creditson the finished job and in thedoneevent, usually far below the hold. - Sponsorship is off: every run is paid from the caller's own balance.
- Runs need a signed-in user token. A guest token can call
/meand/estimateonly; sign in for a personal token on the token page. - A reformat retry (with
retry_note) is a new attempt with its own key and its own charge.
Invariants worth asserting
- The reply is one JSON object whose
taskequals thetaskyou sent, with every key of that lane's contract present. - Review: every flag id in
facts.flagsis answered once inflag_responses, in id order, and no other id is; asource: "tool"flag is only everconfirmed; the status is neverreadywhile a high flag stands. - Every
findings[].commandis a number fromfacts.commandsornull. - A returned script starts with
#!/bin/bashandset -euo pipefail, and parses with no deepTools 3.5.6 error when pasted into the page; values you did not give appear asTO_FILLand into_confirm, never as invented file names, read lengths, fragment lengths or genome builds. - Plan:
statusisdraftandnormalization.methodis one of the listed values.