← Vector Desk / API
Tokens

Drive Vector Desk from your own code

The page reads one pasted vector index in the browser for free — one or more files joined into one paste, each introduced by a # file: <path> line: pgvector SQL (the table, its CREATE INDEX, the query and its SET statements), a Qdrant collection and search body, FAISS or hnswlib code, workload.yaml with the vector count and the budgets, and a parameter sweep CSV — with its line numbers. It reads every index (engine, type, metric, dimensions, build parameters, quantization), every query (its k or LIMIT, ef, probes, filter and the index it can use), the pgvector settings, the workload and the sweep; estimates the memory the index needs; matches the configured setting to a sweep row and picks the cheapest row inside every budget. Then it runs 23 deterministic checks drawn from vector index tuning practice (catalogue ids VX-01..VX-23, plus VX-24 for context): whether the engine accepts the configuration and it matches the embeddings (options, dimensions, metric, credentials), whether every query uses its index and gets the rows it asks for (operators, ef_search against the LIMIT, probes, filters, rescoring, exact search), whether the index fits its memory, and whether the evidence supports the chosen setting (the sweep against the budgets). Nothing is built, loaded or queried: every flag is a reading of the text. Its two paid steps are available over HTTP. You send one JSON object with task set to "audit" or "fix", the paste as code and the page’s check as a facts string, and the model replies with one JSON object: an audit that rates the index configuration, the search settings, the memory and the evidence, names the operating point, answers every flag and gives exact before/after edits per finding, or the whole fixed file(s) with a list of every change tied to the page’s flags.

Base URL: https://api.skillsafe.ai/v1/app-api. Every call sends Content-Type: application/json and, once you have a token, Authorization: Bearer <token>. The paths below (/guest, /me, /estimate, /run, /jobs/{job_id}, /run-stream) are relative to it, exactly as sdk.js calls them. A plain-text summary of the app for agents is at /llms.txt. Both lanes run on the gpt-terra model with a 10% publisher markup. A run needs a signed-in (user) token and is metered: the platform places a hold on your credits when the run starts and then charges only what the run used. /estimate is free.

The task field: two lanes

task is the first field of every body. It routes the run, and it takes exactly two values: "audit" and "fix". Each lane has its own reply contract, and a reply never mixes them. The skill’s instructions tell the model to do the audit lane when task is missing or anything else (and the page’s own Vectorkit.buildInput turns any lane that is not "fix" into "audit"), so a typo silently gets you an audit: always send one of the two values.

taskWhat it doesverdictUse it when
"audit"Reviews the index the way a search engineer reviews it before it serves traffic: says which engine and index type, how many vectors of what dimension under which metric, and the budgets it must meet (index), rates the four areas (config, search, memory, evidence) as sound, fragile or broken, names the operating_point (a sweep row inside every budget, quoted exactly, or row: null), confirms or dismisses every page flag, adds what text rules cannot see, and gives the verbatim evidence and exact before/after edits for each finding, listed in the order to apply them — what the engine refuses and what leaks first, then what makes results wrong or short, then what costs recall, latency or memory, then hygiene — with a plan that says which steps rebuild the index and ends in a recall sweep on real queries.blocked | needs_work | readyBefore an index serves traffic, or when recall, latency or memory is off: does the engine accept it, does every query use it, and what should change first.
"fix"Writes the fixed configuration: the whole of every pasted file it changes (never a new file, never the workload or the sweep), with every table, column, index and collection kept — plus a changes list tied to the page flags (each naming what it changes: an index, a query, a setting, the search params), the verify steps (the engine accepts it, EXPLAIN (ANALYZE) for pgvector, the rebuild, a recall sweep), and what it left open for a fact, a measurement or a decision only the engineer has.fixed | partialWhen you want the files changed, not a list of findings: an index you already know is broken, or what is left after an audit.

The model never invents a measurement. A recall, a latency or a memory size comes from the workload, the sweep, the page’s estimate or the note; a parameter it chooses without a sweep (an ef_search, lists, nprobe) follows the engine’s documented guidance and is called a starting point to measure. It never copies a credential it finds in the paste, and it never edits the workload or the sweep — they are the measurements the configuration is judged against.

The page chains the two: after an audit, “Apply every edit and fix the rest” puts every audit edit into the paste, switches to the fix lane on that edited paste and adds an audit_handoff string (below) with the audit’s verdict, headline and findings. Over the API that is a second run with task: "fix", the edited paste as code, facts recomputed from the edited paste, and that string. The page drops the handoff as soon as the paste is edited again.

Guards the page applies before a run and the API does not: it refuses to price or run a paste it cannot read (“Nothing was pasted.”, “No vector index file was found. ...”, or “No vector index or vector query was found: no pgvector CREATE INDEX or ORDER BY distance, no Qdrant collection or search body, and no FAISS or hnswlib index.”), and it warns before a run on a paste with no index definition, no workload.yaml or no query.

One worked example per lane follows, both examples the page ships (the example buttons on the app page; the full pastes are in /example.js, ids lumen and meridian), built exactly as the page builds them: Vectorkit.buildInput(lane, Vectorkit.analyze(text), note, null), with the saved model reply for each. The other two examples are audits that reach the other verdicts: tessellate (needs_work, 3 flags (3 medium)) and quarry (ready, no flag); the lumen audit also carries a saved fix run of its edited files (fixed). The request bodies cut the long code and facts strings with a visible ...; the replies are shown whole, pretty-printed, exactly as saved. The companies, tables, collections, passwords and numbers are invented.

Worked example, task: "audit": a pgvector HNSW index built with vector_l2_ops that a cosine query cannot use, with ef_construction below 2 * m, ef_search below the LIMIT, a tenant filter without iterative scan and a role password in the schema

3 files: db/schema.sql, db/search.sql, workload.yaml. The page’s free check raises 7 flags (4 high, 1 medium, 2 low) (plus the VX-24 context flag); its own reading of the verdict is blocked. Running build-check.js on it (below) prints 3 file(s), 1 index(es), 1 query, about 25.2 GiB resident; 7 flags (4 high, 1 medium, 2 low); verdict_candidate blocked. The engineer’s note: “We run pgvector 0.8.0 on a 64 GiB box. p99 is around 900 ms and EXPLAIN shows a sequential scan on doc_chunks. The embeddings are text-embedding-3-small, which returns unit-length vectors. We can rebuild the index tonight with CREATE INDEX CONCURRENTLY. What do we fix first?”

The request body (POST /run or /run-stream), idempotency key vector-desk:audit:613c0bcd-1ad6:a1:

{
  "task": "audit",
  "code": "# file: db/schema.sql\nCREATE EXTENSION IF NOT EXISTS vector;\n\nCREATE TABLE doc_chunks (\n  id bigserial PRIMARY KEY,\n  tenant_id bigint NOT NULL,\n  doc_id bigint NOT NULL,\n  body text NOT NULL,\n  embedding vector(1536) NOT NULL\n);\n\nCREATE INDEX doc_chunks_tenan...",
  "facts": "{\"files\":[{\"path\":\"db/schema.sql\",\"kind\":\"sql\",\"line\":2,\"end_line\":19},{\"path\":\"db/search.sql\",\"kind\":\"sql\",\"line\":21,\"end_line\":28},{\"path\":\"workload.yaml\",\"kind\":\"workload\",\"line\":30,\"end_line\":40}],\"indexes\":[{\"engine...",
  "note": "We run pgvector 0.8.0 on a 64 GiB box. p99 is around 900 ms and EXPLAIN shows a sequential scan on doc_chunks. The embeddings are text-embedding-3-small, which returns unit-length vectors. We can rebuild the index tonight with CREATE INDEX CONCURRENTLY. What do we fix first?"
}

The saved reply (output.output), verdict blocked, 6 findings, 4 edits:

{
  "task": "audit",
  "verdict": "blocked",
  "headline": "CREATE INDEX fails outright, the query's cosine operator couldn't use even a fixed index, and a database password is committed to schema.sql.",
  "index": "pgvector HNSW index doc_chunks_embedding_idx (m=24, ef_construction=40, vector_l2_ops) over 4.2M 1536-dimension text-embedding-3-small vectors that the workload compares by cosine. It must serve a tenant-filtered top-50 query at >=0.95 recall and p99 <=60 ms inside a 64 GiB memory budget.",
  "areas": [
    {
      "area": "config",
      "status": "broken",
      "note": "ef_construction (40) is below 2x m (48) so CREATE INDEX fails, and a role password is committed in schema.sql."
    },
    {
      "area": "search",
      "status": "broken",
      "note": "The cosine ORDER BY can't use the l2-opclass index, ef_search (32) is below the query's LIMIT (50), and the tenant filter has no iterative scan to protect that LIMIT."
    },
    {
      "area": "memory",
      "status": "sound",
      "note": "The ~25.2 GiB estimate fits well inside the 64 GiB budget, though full-precision vectors leave quantization savings unclaimed."
    },
    {
      "area": "evidence",
      "status": "sound",
      "note": "No sweep was pasted, so the operating point below is unmeasured; a recall/latency sweep at k=50 is needed to confirm it."
    }
  ],
  "operating_point": {
    "row": null,
    "m": 24,
    "ef_construction": 64,
    "ef_search": 50,
    "nprobe": null,
    "recall": null,
    "p99_ms": null,
    "memory_gib": null,
    "why": "No sweep was pasted: ef_construction=64 only clears pgvector's 2x m minimum for m=24, and ef_search=50 is the documented floor (the query's LIMIT), not a measured recall - run a sweep at k=50 against exact ground truth once the index is rebuilt to confirm recall, p99 and memory against the 0.95/60ms/64GiB budgets."
  },
  "findings": [
    {
      "id": "VX-01",
      "flags": [
        "F1"
      ],
      "file": "db/schema.sql",
      "line": 16,
      "severity": "high",
      "area": "config",
      "title": "ef_construction below 2x m: CREATE INDEX fails",
      "evidence": "USING hnsw (embedding vector_l2_ops)\nWITH (m = 24, ef_construction = 40);",
      "why": "pgvector refuses ef_construction below 2 * m; with m=24 the minimum is 48, but this index sets ef_construction=40, so CREATE INDEX doc_chunks_embedding_idx errors out, no index exists, and every query on embedding is a sequential scan of all 4.2M rows - matching the sequential scan and ~900 ms p99 the note reports.",
      "edits": [
        {
          "before": "  WITH (m = 24, ef_construction = 40);",
          "after": "  WITH (m = 24, ef_construction = 64);"
        }
      ]
    },
    {
      "id": "VX-07",
      "flags": [
        "F2"
      ],
      "file": "db/schema.sql",
      "line": 19,
      "severity": "high",
      "area": "config",
      "title": "Role password committed to schema.sql",
      "evidence": "GRANT SELECT ON doc_chunks TO search_reader;",
      "why": "The CREATE ROLE statement above this line writes search_reader's password as a literal in source, so anyone who can read schema.sql can read and reuse it. Rotate it now and set the new password out of band (psql's \\password search_reader or a secret manager at deploy time), not in the file.",
      "edits": []
    },
    {
      "id": "VX-09",
      "flags": [
        "F4",
        "F6"
      ],
      "file": "db/schema.sql",
      "line": 15,
      "severity": "high",
      "area": "search",
      "title": "Index opclass (l2) doesn't match the query's cosine operator or the workload's metric",
      "evidence": "CREATE INDEX doc_chunks_embedding_idx ON doc_chunks\nUSING hnsw (embedding vector_l2_ops)",
      "why": "db/search.sql:27 orders by embedding <=> $1 (cosine) and the workload's embeddings are compared by cosine, but this index's opclass is vector_l2_ops (<->), so Postgres cannot use it for that ORDER BY - it seq-scans every row, matching the sequential scan and ~900 ms p99 the note reports. Rebuilding with vector_cosine_ops also removes the l2-vs-cosine score-scale mismatch (the vectors are unit length per the note, so rankings already agreed, but scores read on the wrong scale).",
      "edits": [
        {
          "before": "  USING hnsw (embedding vector_l2_ops)",
          "after": "  USING hnsw (embedding vector_cosine_ops)"
        }
      ]
    },
    {
      "id": "VX-08",
      "flags": [
        "F3"
      ],
      "file": "db/search.sql",
      "line": 22,
      "severity": "high",
      "area": "search",
      "title": "hnsw.ef_search (32) below the query's LIMIT (50)",
      "evidence": "SET LOCAL hnsw.ef_search = 32;",
      "why": "HNSW returns at most hnsw.ef_search rows, so this query can get at most 32 of the 50 it asks for even once the index builds and can be used - fewer still once the tenant filter runs. Raise it to at least the LIMIT (50); with no sweep pasted, treat 50 as a floor to measure from, not a value proven to hit the 0.95 recall target.",
      "edits": [
        {
          "before": "SET LOCAL hnsw.ef_search = 32;",
          "after": "SET LOCAL hnsw.ef_search = 50;"
        }
      ]
    },
    {
      "id": "VX-12",
      "flags": [
        "F5"
      ],
      "file": "db/search.sql",
      "line": 24,
      "severity": "medium",
      "area": "search",
      "title": "Tenant filter drops rows without an iterative scan",
      "evidence": "SELECT id, doc_id, body, embedding <=> $1 AS distance\nFROM doc_chunks\nWHERE tenant_id = $2",
      "why": "WHERE tenant_id = $2 runs after the HNSW scan returns its ef_search candidates (32 today); at 4% selectivity only about 1.3 of those 32 pass, so the query returns far fewer rows than its LIMIT of 50, and the same proportional shortfall remains even once ef_search is raised. pgvector 0.8.0, confirmed in the note, adds SET LOCAL hnsw.iterative_scan = relaxed_order to keep scanning until enough rows pass the filter.",
      "edits": [
        {
          "before": "\nSELECT id, doc_id, body, embedding <=> $1 AS distance",
          "after": "\nSET LOCAL hnsw.iterative_scan = relaxed_order;\n\nSELECT id, doc_id, body, embedding <=> $1 AS distance"
        }
      ]
    },
    {
      "id": "VX-16",
      "flags": [
        "F7"
      ],
      "file": "db/schema.sql",
      "line": 14,
      "severity": "low",
      "area": "memory",
      "title": "Full-precision vectors at a scale where quantization pays",
      "evidence": "CREATE INDEX doc_chunks_embedding_idx ON doc_chunks\nUSING hnsw (embedding vector_l2_ops)\nWITH (m = 24, ef_construction = 40);",
      "why": "At 4.2M x 1536 dims the full-precision index needs about 25.2 GiB resident (facts.estimate) - inside the 64 GiB budget today - but int8 scalar quantization (or a halfvec expression index, or binary_quantize with rescoring) would cut that several-fold at a small, recoverable recall cost as the table grows. Left as a decision for the team; no sweep exists yet to size a quantized variant against the budgets.",
      "edits": []
    }
  ],
  "flag_answers": [
    {
      "flag": "F1",
      "status": "confirmed",
      "reason": "ef_construction=40 < 2 * m (48); raised to 64."
    },
    {
      "flag": "F2",
      "status": "confirmed",
      "reason": "Password literal in schema.sql; must be rotated and set out of band, not edited in place."
    },
    {
      "flag": "F3",
      "status": "confirmed",
      "reason": "ef_search=32 < LIMIT 50; raised to 50."
    },
    {
      "flag": "F4",
      "status": "confirmed",
      "reason": "Query orders by <=> (cosine) against a vector_l2_ops index; opclass switched to vector_cosine_ops."
    },
    {
      "flag": "F5",
      "status": "confirmed",
      "reason": "Filtered query has no iterative scan; added SET LOCAL hnsw.iterative_scan = relaxed_order."
    },
    {
      "flag": "F6",
      "status": "confirmed",
      "reason": "Index ranked by l2 while the workload's metric is cosine; resolved by the same opclass change as F4."
    },
    {
      "flag": "F7",
      "status": "confirmed",
      "reason": "4.2M full-precision vectors (~25.2 GiB) fit the 64 GiB budget today; quantization would help further but is left open."
    }
  ],
  "plan": [
    "Rebuild doc_chunks_embedding_idx with CREATE INDEX CONCURRENTLY using ef_construction=64 (>= 2 * m) and opclass vector_cosine_ops, then drop the old index - this fixes the build failure and lets the query's cosine ORDER BY use the index.",
    "Rotate the search_reader credential now; recreate the role without a literal password (CREATE ROLE search_reader LOGIN;) and set the new password out of band (psql's \\password or a secret manager).",
    "Raise SET LOCAL hnsw.ef_search to at least 50 (the query's LIMIT) - query-time only, no rebuild.",
    "Add SET LOCAL hnsw.iterative_scan = relaxed_order for the tenant-filtered query (pgvector 0.8.0) - query-time only.",
    "Re-run a recall sweep of a few hundred real queries against exact ground truth at k=50, and EXPLAIN (ANALYZE) the query to confirm it scans doc_chunks_embedding_idx and returns the full 50-row LIMIT.",
    "Once measured, evaluate int8 scalar quantization to cut the ~25.2 GiB resident footprint further."
  ],
  "questions": [
    "Can search_reader's password be rotated tonight alongside the index rebuild, and will the new one live in an env var or secret manager?",
    "What sustained QPS should the rebuilt index and ef_search/iterative_scan settings be sized against?"
  ],
  "summary": "Fix the index definition first: rebuild doc_chunks_embedding_idx with ef_construction=64 and opclass vector_cosine_ops in one CREATE INDEX CONCURRENTLY, since right now it doesn't build at all and, even built as configured, the query's cosine ORDER BY couldn't use an l2 index - that's the sequential scan and ~900 ms p99 you're seeing. Rotate the search_reader password out of schema.sql at the same time; it's a live credential in source. Then raise hnsw.ef_search to at least 50 and add hnsw.iterative_scan = relaxed_order (available in your 0.8.0) so the tenant filter doesn't starve the LIMIT. No sweep was pasted, so re-measure recall, p99 and memory at k=50 after the rebuild before locking ef_search or considering quantization."
}

The credential finding carries no edit: it cannot be fixed without quoting the value, so the fix lane rewrites the line. The page applies the other edits in order and re-checks: 4 edit(s) applied, 0 did not; with every edit in place the free check raises 1 high, 0 medium and 1 low flag(s).

Worked example, task: "fix": a FAISS IVF65536,PQ60 index over 768-dimension vectors whose PQ split does not divide the dimension, whose lists outnumber what 2M vectors can train, and whose nprobe is never set

2 files: search/ivfpq_index.py, workload.yaml. The free check raises 3 flags (1 high, 2 medium); build-check.js prints 2 file(s), 1 index(es), 1 query, about 0.3 GiB resident; 3 flags (1 high, 2 medium, 0 low); verdict_candidate blocked. The engineer’s note: “build() stops with an assertion inside faiss. Fix what you can. We train on 200,000 sampled vectors. Leave nprobe for us: we will set it from the recall benchmark we run next week.”

The request body, idempotency key vector-desk:fix:dc724a33-ed3:a1:

{
  "task": "fix",
  "code": "# file: search/ivfpq_index.py\nimport faiss\nimport numpy as np\n\nDIM = 768\n\n\ndef build(train: np.ndarray, vectors: np.ndarray) -> faiss.Index:\n    index = faiss.index_factory(DIM, \"IVF65536,PQ60\")\n    index.train(train)\n    index.add(vectors)\n    return index\n\n\n...",
  "facts": "{\"files\":[{\"path\":\"search/ivfpq_index.py\",\"kind\":\"python\",\"line\":2,\"end_line\":16},{\"path\":\"workload.yaml\",\"kind\":\"workload\",\"line\":18,\"end_line\":25}],\"indexes\":[{\"engine\":\"faiss\",\"type\":\"ivf\",\"file\":\"search/ivfpq_index.p...",
  "note": "build() stops with an assertion inside faiss. Fix what you can. We train on 200,000 sampled vectors. Leave nprobe for us: we will set it from the recall benchmark we run next week."
}

The saved reply, verdict partial, 1 file(s), 1 change(s), 1 open item(s):

{
  "task": "fix",
  "verdict": "partial",
  "headline": "Fixed the FAISS factory string's PQ divisor and list count; left nprobe for your benchmark.",
  "files": [
    {
      "path": "search/ivfpq_index.py",
      "content": "import faiss\nimport numpy as np\n\nDIM = 768\n\n\ndef build(train: np.ndarray, vectors: np.ndarray) -> faiss.Index:\n    index = faiss.index_factory(DIM, \"IVF4096,PQ64\")\n    index.train(train)\n    index.add(vectors)\n    return index\n\n\ndef search(index: faiss.Index, queries: np.ndarray):\n    return index.search(queries, 20)\n"
    }
  ],
  "changes": [
    {
      "file": "search/ivfpq_index.py",
      "object": "index `index`",
      "flags": [
        "F1",
        "F2"
      ],
      "change": "Changed the factory string from \"IVF65536,PQ60\" to \"IVF4096,PQ64\": 64 sub-quantizers divide the 768-dimensional vectors evenly, and 4,096 lists fit the 200,000-vector training sample (about 49 vectors per centroid, above FAISS's 39-vector floor) and give about 488 vectors per list at 2M vectors, instead of 65,536 lists (31 vectors per list, only about 3 training vectors per centroid).",
      "why": "Stops faiss.index_factory raising its PQ-divisibility assertion (VX-06) and stops each inverted list being trained and searched on too few vectors, which was starving recall (VX-04)."
    }
  ],
  "verify": [
    "Run build() with the 200,000-vector training sample and confirm faiss.index_factory(768, \"IVF4096,PQ64\") no longer raises the PQ assertion and train() completes without a 'few points' k-means warning.",
    "After add(), check index.ntotal == len(vectors) and that each of the 4,096 lists holds roughly vectors/4096 (~488) entries.",
    "Once nprobe is set from next week's benchmark, sweep a few hundred real queries against exact ground truth at k=20 and confirm recall reaches target_recall 0.9 with p99 under 30 ms and memory under 4 GiB."
  ],
  "open": [
    {
      "item": "nprobe is left unset (FAISS default of 1), so the rebuilt index still searches only one of the 4,096 lists per query",
      "flags": [
        "F3"
      ],
      "reason": "You asked to leave nprobe for your team to set from the recall benchmark you're running next week; the right value needs that measurement, not a guess."
    }
  ],
  "questions": [],
  "summary": "build() was crashing because PQ60 does not divide 768 dimensions and because 65,536 IVF lists left far too few vectors per list and per training centroid for your 200,000-vector sample; switching the factory string to \"IVF4096,PQ64\" fixes both. nprobe stays at its default as you asked, since you'll tune it from next week's benchmark - it's listed under open. workload.yaml was not touched."
}

The fixes use only what the note gives (the training sample size); what the note asks to leave (nprobe, until the benchmark) stays open, so the verdict is partial.

The response envelope

Responses are JSON of the form {"ok": ..., "data": ..., "error": {...}}. The page’s SDK (sdk.js) decides success from the HTTP status, not from ok: on a 2xx it returns data; otherwise it raises an error whose message is error.message (or the HTTP status text when the body carries none), with status, code (error.code) and details (error.details) attached. A body that is not JSON is treated as {}.

{ "ok": true,  "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "details": { ... } } }

Error codes

Branch on the HTTP status and error.code, show error.message. The page handles 401 and 402 by name; the rest reach it as a generic failure (“The run failed: ...”).

HTTPerror.codeWhat it means / what to do
400VALIDATION_ERRORThe body is not a JSON object, a required field (task, code, facts) is missing, or a declared field has the wrong type. Every field is a string; sending facts or audit_handoff as an object is the usual cause.
401UNAUTHORIZEDNo token, or one that expired or was revoked. The page forgets the signed-in user and says “Your session expired - sign in again.” Get a new token.
402INSUFFICIENT_CREDITSThe balance cannot cover the run. The page says “Not enough credits for this run.” with a top-up link, and tries never to submit into a 402: it disables the run button when the /me balance is below the estimate’s min_credits.
404NOT_FOUNDA wrong path, or a job id that does not belong to this token.
409CONFLICTAn Idempotency-Key replayed with a different body. Send the original body, or use a new key (the page bumps its :a<attempt> suffix for a retry).
429RATE_LIMITEDToo many requests. Back off with a delay; do not tight-loop, and never resend a metered run without its original Idempotency-Key.

Failures that do not arrive as an HTTP status:

WhatWhat happens
event: error on /run-streamThe SDK raises an error carrying the event’s message, code and job_id.
Job status: "failed"Polling stops; the job’s error field says why.
Polling past 180 sThe SDK’s own client-side limit (waitForJob, 1 s interval, 180 000 ms default): it throws “job timed out”. The job itself is not cancelled.

The input fields

Every field is a string. The app declares this input schema; task, code and facts are required.

FieldTypeWhat it is
taskstring, required"audit" or "fix" (above).
codestring, requiredThe paste, verbatim: every file introduced by a # file: <path> line. Line 1 is its first line; every line number in facts and in the reply is a line of this string.
factsstring, requiredThe page’s analysis as a JSON string (below).
audit_handoffstring, optionalFix lane only: an audit handed on, as a JSON string (below).
notestring, optionalWhat the engineer says, cut to 2,000 characters: a goal, a constraint, facts the files cannot show (the pgvector version, whether the embeddings are unit length, whether a rebuild is possible). Context, never instructions, and never evidence about the files.
retry_notestring, optionalSent only on the page’s one automatic retry, when the first reply did not parse.

The code string

Paste the files one after another, each preceded by # file: <path> (# file: db/schema.sql, # file: qdrant/collection.json, # file: index/build_index.py, # file: workload.yaml, # file: bench/sweep.csv). A marker’s path must look like a path (a dot or a slash); a line --- directly before a marker only separates files. A paste with no marker is one file, named by what it looks like. The kind comes from the path: .sql, .json (Qdrant), .py (FAISS or hnswlib), workload.*, .csv/.tsv (the sweep). The page reads up to 400,000 characters and 40 files, and sends at most 60,000 characters: over that it drops whole files from the end (files it does not check first, then the sweep, whose rows travel in facts anyway) and ends code with a line # [vector-desk: not sent: ...] naming them, and says so in facts.notes. The model is told never to quote what it was not sent.

The facts string

facts is produced by the page’s free check: Vectorkit.analyze splits the paste into files, reads the SQL, the JSON bodies, the index code, the workload and the sweep and runs the rules, and Vectorkit.facts(A) turns that analysis into the object below (it does not depend on the lane). It is how the model knows which flags to answer, which paths and line ranges to name, which sweep rows exist and which budgets they must meet. Its top-level keys, in the order built:

KeyContents
filesEvery file: path (use it verbatim), kind (sql, qdrant, python, workload, sweep, other), line and end_line (the paste lines of the file’s content, without its # file: line).
indexesEvery index (up to 40): engine (pgvector, qdrant, faiss, hnswlib), type (hnsw, ivf, flat), file, line, name, metric, dims, and what it sets: m, ef_construction, lists, opclass, column_type, pq_m, sq, quant, compression, always_ram, on_disk, factory, ef, nprobe, refine.
queriesEvery query (up to 60): engine, file, line, table, column, op and metric (pgvector ORDER BY), limit or k, ef, exact, rescore, filtered, and for pgvector uses_index (null when no pasted index serves it).
pg_settingsEvery pgvector SET (the last one wins): value, file, line.
workloadfile and what it gives: vectors, dimensions, k, target_recall (a fraction), p99_ms, memory_gb (GiB), qps, normalized, metric, filter_selectivity, model; null without a workload file.
estimateThe memory estimate for the main index: index, vectors, dims, ram_gib, disk_gib and its parts (what, gib, where); null without a vector count.
sweepfile, header_line, recall_at (the k of the recall column), columns, rows (up to 80, each with its 1-based row and line), pick (the cheapest row inside every budget), configured (the row the pasted setting runs at), configured_param, max_recall, skipped_rows; null without a sweep.
unreadableFiles that could not be read: path, line, error.
flagsEvery flag the page raised: uid (F1, F2, ... in severity, file, then line order), id (VX-01..VX-24), sev (high | medium | low | info), file, line, text, detail, and lines (every line an aggregated flag covers, when there are several).
verdict_candidateThe audit verdict the flags point at: blocked with any high flag, needs_work with any medium, otherwise ready.
notesPlain sentences about what was not read or not sent (empty for a paste that fits).

The catalogue:

IdFlagArea
VX-01pgvector rejects the index or search optionsconfig
VX-02vector dimensions over pgvector's index limitconfig
VX-03index dimensions disagree with the workloadconfig
VX-04IVF lists out of proportion to the vector countconfig
VX-05index type does not fit the scaleconfig
VX-06PQ sub-quantizers do not divide the dimensionconfig
VX-07credential literal in the pasteconfig
VX-08hnsw.ef_search below the query LIMITsearch
VX-09query distance operator does not match the indexsearch
VX-10metric does not fit the embeddingssearch
VX-11IVF probes left at 1search
VX-12filtered query on an approximate index without iterative scansearch
VX-13quantized search without rescoring at a high recall targetsearch
VX-14search forced to exactsearch
VX-15estimated index memory over the budgetmemory
VX-16full-precision vectors at a scale where quantization paysmemory
VX-17quantized vectors not kept in RAM while the originals are on diskmemory
VX-18no sweep row reaches the target recallevidence
VX-19the configured operating point misses a budget the sweep can meetevidence
VX-20recall falls as the search parameter risesevidence
VX-21every sweep row that meets the recall target is over the latency budgetevidence
VX-22sweep recall measured at a different k than the queries ask forevidence
VX-23the workload is missing a budgetevidence
VX-24contextcontext

The simplest source is vectorkit.js itself: the page loads it from /vectorkit.js, and it also runs in Node (it sets module.exports). This script prints what the page would send as code and facts (with the 60,000-character cut applied), with the facts decoded so you can read them; the step 4 samples compact them back into the page’s string byte for byte. Save the paste as lumen-paste.txt and redirect the output into lumen-check.json:

// build-check.js -- run: node build-check.js lumen-paste.txt > lumen-check.json   (vectorkit.js saved next to it)
const fs = require("fs");
const Vectorkit = require("./vectorkit.js");
const A = Vectorkit.analyze(fs.readFileSync(process.argv[2], "utf8"));
if (!A.ok) { console.error(A.errors.join("\n")); process.exit(1); }
const inp = Vectorkit.buildInput("audit", A, "", null);     // code and facts do not depend on the lane
console.log(JSON.stringify({ code: inp.code, facts: JSON.parse(inp.facts) }, null, 2));
const real = A.flags.filter((f) => f.sev !== "info");
console.error(A.files.length + " file(s), " + A.indexes.length + " index(es), " + A.queries.length + " quer" +
  (A.queries.length === 1 ? "y" : "ies") + (A.estimate ? ", about " + A.estimate.ram_gib + " GiB resident" : "") + "; " +
  real.length + " flags (" + A.counts.high + " high, " + A.counts.medium + " medium, " + A.counts.low + " low); verdict_candidate " + A.candidate);

For the lumen example it reports 3 file(s), 1 index(es), 1 query, about 25.2 GiB resident; 7 flags (4 high, 1 medium, 2 low); verdict_candidate blocked; for the meridian example, 2 file(s), 1 index(es), 1 query, about 0.3 GiB resident; 3 flags (1 high, 2 medium, 0 low); verdict_candidate blocked. For both worked examples its code and facts are exactly what the page sends.

Without the page’s check. The platform only checks that facts is a string, so "{}" works and the model still reads code. But you lose what the check adds: with no flags there is nothing to confirm, dismiss or close, with no files there are no paths and line ranges to name, with no sweep rows the operating point cannot be quoted, and with no estimate the memory findings have nothing to stand on. Build it with vectorkit.js when you can.

The audit_handoff string

Only for task: "fix", and only for the files the audit was about with every audit edit applied. The page builds both from the normalised audit reply res (recon.js normalize) and the analysis A of the audited paste, in recon.js fixHandoff:

const run = Recon.applyFindings(A, res.findings, Vectorkit);  // every finding's edits, in order, each on top of the last
JSON.stringify({ verdict: res.verdict, headline: res.headline,
  findings: res.findings.map(f => ({ id: f.id, severity: f.severity, file: f.file, line: f.line, title: f.title, why: f.why, edits: f.edits.length })),
  applied: /* edits that applied */, failed: /* edits that did not */ })
// run.A.text is the edited paste: it becomes code, and facts is recomputed from it

Each finding’s edits there is the number of edits it carried. applyFindings applies every finding’s edits with Vectorkit.applyEdits, in the order listed, to the finding’s file: each before must occur exactly once in that file as the earlier edits left it (blank lines at either end are tolerated, and a blank line written inside after is kept), or that edit is reported as failed and skipped — it is never guessed. This script does the whole step, in the same shape as build-check.js prints:

// handoff.js -- run: node handoff.js lumen-paste.txt lumen-reply.txt > lumen-fix-check.json   (vectorkit.js, recon.js next to it)
const fs = require("fs");
const Vectorkit = require("./vectorkit.js");
const Recon = require("./recon.js");
const A = Vectorkit.analyze(fs.readFileSync(process.argv[2], "utf8"));          // the audited paste
const res = Recon.normalize(Recon.parseResult(fs.readFileSync(process.argv[3], "utf8")), "audit");
const ho = Recon.fixHandoff(A, res);                                            // every edit, in order
const B = Vectorkit.analyze(ho.text);                                           // facts recomputed from the edited paste
const inp = Vectorkit.buildInput("fix", B, "", ho.handoff);
console.log(JSON.stringify({ code: inp.code, facts: JSON.parse(inp.facts), audit_handoff: inp.audit_handoff }, null, 2));
console.error(ho.applied + " edit(s) applied, " + ho.failed + " did not apply");

For the lumen audit above it reports 4 edit(s) applied, 0 did not apply; the edited paste raises 1 high, 0 medium and 1 low flag(s), and the page sends that fix run with the audit’s note and the key vector-desk:fix:d77387c8-1f02:a1. Its saved reply is fixed (1 file(s), 1 change(s), 0 open item(s)).

The output contracts

The model is instructed (SKILL.md) to reply with one JSON object and nothing else — no prose, no code fences — with every array of its lane present ([] when empty). The run’s output text is that object. In both lanes it grounds everything in code: the servers, their memory and CPU, the data distribution, other queries and the engine version are outside the paste, so they are asked about, never stated as facts. It never invents a measurement (a recall, a latency, a memory size comes from the workload, the sweep, the page’s estimate or the note); it never writes a secret value (a line that holds a credential is never copied into the reply); it never edits the workload or the sweep; it keeps every file valid in its own format; and it keeps the system’s meaning: which tables, columns, indexes, collections and named vectors exist, and which column or collection each query searches. Every page flag whose sev is not info is answered exactly once (audit lane: in flag_answers), and an info flag is never a finding.

task: "audit"

task: "fix"

How the page reads a reply (recon.js parseResult and normalize): it strips a code fence and parses from the first { to the last }. A reply is read as the lane that was asked for, unless it names the other lane and carries that lane’s body (a files array for fix, a findings array for audit). An unknown verdict shows as needs_work or partial and counts as a disagreement; an unknown severity becomes medium; ids like VX7 become VX-07; flags keeps only F<n> values; index may also arrive as system or setup, file as path, a change’s object as target and its change as what; questions is capped at 10, plan at 30 and verify at 20. An audit reply carries 10 sections (verdict, headline, index, areas, operating_point, findings, flag_answers, plan, questions, summary) and a fix reply 8 (verdict, headline, files, changes, verify, open, questions, summary), which is what the page counts when it recovers a partial reply.

The page checks the reply against the files; the API does not

What comes back from the API is the model’s raw text in output.output. The page holds it to account in the browser before showing it (recon.js reconcile, with the facts, the code and the handoff it sent): it applies every edit or returned file to the paste and re-runs its own checks on the result. None of that happens in the API.

recon.js also runs in Node, so you can hold a reply to exactly the page’s standard. Save /vectorkit.js and /recon.js next to this script:

// recheck.js -- run: node recheck.js lumen-check.json lumen-reply.txt audit > lumen-edited.txt   (vectorkit.js, recon.js next to it)
const fs = require("fs");
const Recon = require("./recon.js");                                            // loads ./vectorkit.js itself
const sent = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));             // code, facts (and audit_handoff) you sent
const lane = process.argv[4] || "audit";
const res = Recon.normalize(Recon.parseResult(fs.readFileSync(process.argv[3], "utf8")), lane);  // output.output
const r = Recon.reconcile(res, { facts: sent.facts, code: sent.code, handoff: sent.audit_handoff || null });
r.notes.forEach((n) => console.error(n.kind + ": " + n.text));
console.error(r.disagreements + " disagreement(s) in " + r.statements + " statements checked");
if (r.after) (r.after.flags || []).forEach((f) => console.error("still raised: " + f.id + " " + f.sev + " " + f.file + ":" + f.line + " " + f.text));
if (r.after && r.after.text) console.log(r.after.text.replace(/\n$/, ""));      // the paste with every edit (or file) put back

For the lumen audit reply it reports:

ok: The areas table rates the index configuration, the search settings, the memory and the evidence once each.
ok: Every one of the 6 findings names a pasted file, a line inside it and evidence copied from it.
ok: No sweep was pasted and the operating point names no row.
ok: The verdict (blocked) fits the worst finding.
ok: Every one of the page's 7 flags is answered once.
ok: All 4 edits applied in order, each before text found exactly once.
ok: Every confirmed flag a finding carries is gone once all edits are applied.
ok: Every table, column, index and collection of the paste is still there after the edits (3 names: table doc_chunks, column doc_chunks.embedding, index doc_chunks_embedding_idx).
ok: The reply repeats no credential from the paste (1 found in it).
0 disagreement(s) in 9 statements checked
still raised: VX-07 high db/schema.sql:18 a credential is written into db/schema.sql (a role password)
still raised: VX-16 low db/schema.sql:14 index doc_chunks_embedding_idx keeps 4.2M x 1536 float32 vectors (24 GiB) at full precision

and for the meridian fix reply (node recheck.js meridian-check.json meridian-reply.txt fix):

ok: 1 pasted file returned whole.
ok: With the fixed files in place the free check raises no high or medium flag beyond the ones left open (0 low).
info: F3 left open by the reply.
ok: Every table, column, index and collection is still there (1 name: python index index).
ok: Every one of the 1 changes names a returned file and real page flags.
ok: The verdict (partial) fits the open list.
ok: The reply repeats no credential from the paste.
0 disagreement(s) in 6 statements checked
still raised: VX-11 medium search/ivfpq_index.py:9 the FAISS "IVF4096,PQ64" index searches with nprobe = 1 (never set, FAISS's default)

The page does not build or query anything. After that, run the verify steps: the engine accepts the change, the rebuild when one is needed, and a recall sweep on real queries with p99 and memory against the budgets, plus whatever the reply’s plan names.

Step 1 — a tiny client helper

One function per language that does what sdk.js does for every call: JSON in, bearer token if you have one, data out on a 2xx, an error carrying status, error.code and error.message otherwise. The later steps assume this helper is in the same file (for Go, Java and C#, the later snippets go inside main / the top-level statements; Java’s makeInput from step 4 goes in the class).

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="${SKILLSAFE_TOKEN:-YOUR_TOKEN}"   # step 2

# api METHOD PATH [extra curl args...] -- prints the whole envelope; pipe to jq
api() {
  method="$1"; path="$2"; shift 2
  curl -sS -X "$method" "$BASE$path" \
    -H "Content-Type: application/json" \
    -H "Authorization: Bearer $TOKEN" "$@"
}
# success: jq '.data'      failure: jq '.error | {code, message}'

Step 2 — a token

Two kinds work:

# personal token (needed for a run): copy it from /tokens.html, then
#   export SKILLSAFE_TOKEN=...        and skip the rest of this block
# guest token (enough for /me and /estimate): no Authorization header on this one call
TOKEN=$(curl -sS -X POST "$BASE/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"vector-desk"}' | jq -r '.data.token')
echo "guest token: ${TOKEN:0:12}..."

Step 3 — GET /me

Who the token belongs to and what it can spend. sdk.js documents the shape as {subject_type, subject_id, credits, profile?}. The page treats the caller as signed in only when subject_type is "user", shows credits as the balance (1 credit = $0.0001, so it divides by 10 000 for the dollar figure), and compares it with the estimate in step 4 before it lets you run.

api GET /me | jq '.data // .error'

Step 4 — build the input, then POST /estimate

First a small makeInput that does what buildInput does: task, then code as the paste the page would send, facts as a compact JSON string, the note when it is not blank, and — for the fix lane only — the audit_handoff string when the file carries one. It reads the file build-check.js (or handoff.js) printed. The samples build the audit input of the lumen example from lumen-check.json; step 6 reuses makeInput for the meridian (fix) example. Then price it. The body of /estimate is the same input you will send to /run; no job is created and nothing is charged — /estimate is free. From the response the page reads:

FieldMeaning
hold_creditsA reservation, not a price: the worst case the platform holds while the run is in flight. The page shows it as “reserves N credits ($...) for the audit run” and adds that you are charged only for what the run actually uses, usually far less. What you pay is the job’s charged_credits after settlement (step 5). A balance below the hold is allowed, but the page warns that the reply may be cut short (truncated: true).
min_creditsThe smallest balance that can start a run. Below it the page disables the run button and names the shortfall, because the call would be a 402.
sponsor_enabledWhen true, a caller who is not signed in may run; otherwise the page asks them to sign in.
warningsInput warnings. They do not refuse the call; the page shows them next to the price (“input warnings: ...”), so check them before you run.

The page re-prices whenever the paste, the note or the lane change. The amounts grow with the size of code and facts, so read them from the call rather than hard-coding them.

# lumen-check.json: printed by build-check.js (see "The facts string").
# (.facts | tojson) turns the facts into ONE compact string, as JSON.stringify does.
BODY=$(jq -c --arg note "We run pgvector 0.8.0 on a 64 GiB box. p99 is around 900 ms and EXPLAIN shows a sequential scan on doc_chunks. The embeddings are text-embedding-3-small, which returns unit-length vectors. We can rebuild the index tonight with CREATE INDEX CONCURRENTLY. What do we fix first?" \
  '{task: "audit", code, facts: (.facts | tojson), note: $note}' lumen-check.json)
# No page check at hand? facts: "{}" is accepted as a string, but the reply then has no page flags to answer.
# Fix lane after an audit: read handoff.js's output instead and add its string:
#   '{task: "fix", code, facts: (.facts | tojson), note: $note} + (if .audit_handoff then {audit_handoff} else {} end)'

api POST /estimate -d "$BODY" \
  | jq '.error // (.data | {hold_credits, min_credits, sponsor_enabled, warnings})'

Step 5 — POST /run, then poll the job (audit)

Send the same input to /run with an Idempotency-Key header, using a personal token. Its data carries a job_id. Poll GET /jobs/{job_id} the way sdk.js waitForJob does: every second until status is succeeded or failed, giving up on the client side after 180 seconds. On the finished job, read:

Idempotency key. Resending a request with the same key must not start a second paid run, so derive the key from the input, not from the clock — and include the lane, so an audit and a fix run of the same paste never share a key. The page uses vector-desk:<lane>:<inputHash>:a<attempt> (app.js run), where <lane> is audit or fix, <inputHash> is a 32-bit djb2 hash of JSON.stringify(input) in hex, a dash, and the string’s length in hex (app.js inputHash, over the input without retry_note), and <attempt> is 1 for a normal run and 2 for the automatic reformat retry (which also adds retry_note). For the two worked examples that is vector-desk:audit:613c0bcd-1ad6:a1 (lumen audit) and vector-desk:fix:dc724a33-ed3:a1 (meridian fix); the fix run of the lumen audit handed on (below) is vector-desk:fix:d77387c8-1f02:a1. The format is the page’s convention, not a requirement: any stable, unique string does. The samples below use the lane and a SHA-256 prefix.

The page itself uses /run-stream (step 6), not /run; both take the same body and header.

KEY="vector-desk:audit:$(printf '%s' "$BODY" | shasum -a 256 | cut -c1-16):a1"

JOB=$(api POST /run -H "Idempotency-Key: $KEY" -d "$BODY" | jq -r '.data.job_id')

for i in $(seq 180); do                       # 1 s interval, 180 s client-side limit
  J=$(api GET "/jobs/$JOB")
  S=$(printf '%s' "$J" | jq -r '.data.status')
  if [ "$S" = "succeeded" ] || [ "$S" = "failed" ]; then break; fi
  sleep 1
done

printf '%s' "$J" | jq '.data | {status, truncated, charged_credits, error}'
printf '%s' "$J" | jq -r '.data.output.output' \
  | jq '{verdict, headline, areas: [(.areas // [])[] | {area, status}],
         findings: [(.findings // [])[] | {id, file, line, severity, edits: ((.edits // []) | length)}],
         flag_answers: [(.flag_answers // [])[] | {flag, status}]}'

Step 6 — POST /run-stream (Server-Sent Events, fix)

The alternative to step 5, and what the page uses: same kind of body, same Idempotency-Key header, one request. Run one or the other for a given input, not both. To show the second lane, the samples build the fix input of the meridian example (with its own key) and stream it; to stream the audit input instead, pass the step 4 input and key. The response is text/event-stream. sdk.js splits it into frames on a blank line, takes the event name from an event: line (default message) and concatenates the data: lines, skips frames with no data or with data that is not JSON, and handles these events:

EventDataWhat the SDK does
delta{text}Passes text to onDelta.
jobjob infoPasses it to onJob (the page uses it to advance its progress display).
donethe final payload: {job_id, status, charged_credits, output}Keeps it as the result.
pendinga payloadTreated exactly like done. If it carries a job_id and no output, poll that job as in step 5.
error{message, code, job_id}After the stream ends, raises an error with those fields.

If the response is not text/event-stream, it is an ordinary JSON envelope: the SDK returns its data (an idempotent replay returns this way) or raises the error on a non-2xx status. Handle both shapes.

From the final payload the page reads output.output (falling back to the concatenated delta text), truncated and charged_credits. Browsers receive progress ticks rather than deltas from this endpoint, so the page relies on the final payload; if the stream fails part-way, it tries to recover a partial reply from whatever delta text arrived. The samples print the verdict and headline, each returned file’s path and size, each change (task, flags and change), each verification step, each open item and each question. To put the files into the paste and re-check them the way the page’s “Put every file into the paste” does, keep the reply text and run recheck.js with the lane fix. To fix code you audited, read handoff.js’s output instead of meridian-check.json: makeInput then carries its audit_handoff.

# the fix worked example: meridian-check.json from build-check.js
# (for audited code, read handoff.js's output instead; the + (...) adds its audit_handoff)
FBODY=$(jq -c --arg note "build() stops with an assertion inside faiss. Fix what you can. We train on 200,000 sampled vectors. Leave nprobe for us: we will set it from the recall benchmark we run next week." \
  '{task: "fix", code, facts: (.facts | tojson), note: $note} + (if .audit_handoff then {audit_handoff} else {} end)' \
  meridian-check.json)
FKEY="vector-desk:fix:$(printf '%s' "$FBODY" | shasum -a 256 | cut -c1-16):a1"

# -N turns off buffering; the whole stream lands in $STREAM
STREAM=$(api POST /run-stream -N -H "Idempotency-Key: $FKEY" -d "$FBODY")

# frames look like:
#   event: job
#   data: {...}
#
#   event: done
#   data: {"job_id":"...","status":"...","charged_credits":...,"output":{"output":"{\"task\":\"fix\",...}"}}

# the final reply text, from the done (or pending) frame:
REPLY=$(printf '%s\n' "$STREAM" \
  | awk '/^event:/{ev=$2} /^data:/ && (ev=="done" || ev=="pending"){sub(/^data: */,""); print}' \
  | jq -r '.output.output')
# an idempotent replay answers with a plain JSON envelope instead: REPLY=$(printf '%s' "$STREAM" | jq -r '.data.output.output')
printf '%s' "$REPLY" | jq '{verdict, headline,
  files: [(.files // [])[] | {path, characters: (.content | length)}],
  changes: [(.changes // [])[] | {task, flags, change}], verify, open, questions}'

What the page does that you now do

Or skip all of this and use Vector Desk in the browser: the check, the flags, the index, query, memory and sweep tables, putting an edit or the fixed files back into the paste, and the Markdown, CSV and JSON exports are free.