Drive Chartplumb from your own code
The page reads a pasted Helm chart in the browser for free — several files joined into one
paste, each introduced by a # file: <path> line — with its line numbers,
parses Chart.yaml and values.yaml, scans every template action with the
if/with/range block it sits in, and runs 28 deterministic
checks drawn from Helm chart practice (catalogue ids HC-01..HC-29; HC-29 is context only):
Chart.yaml fields and dependency pinning, Kubernetes APIs current clusters removed,
.Values references that nil-pointer or render empty, .Values inside
range/with without $, includes of templates nobody defines,
toYaml without nindent, unquoted env values, selectors that carry the chart
version, credentials in values, Secret data without b64enc, random passwords with no
lookup, image tags, securityContext, resources, probes, hardcoded namespaces,
values.schema.json and the defaults it must accept, hooks without a delete policy, and
hygiene. Its two paid steps are available over HTTP. You send one JSON object with
task set to "audit" or "schema", the paste as
chart and the page’s check as a facts string, and the model replies
with one JSON object: an audit with the whole corrected file per finding, an answer for every flag
and the order to apply the fixes, or the chart’s values.schema.json (draft-07)
with a values reference and the keys it could not type.
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.
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: ...”).
| HTTP | error.code | What it means / what to do |
|---|---|---|
400 | VALIDATION_ERROR | The body is not a JSON object, a required field (task, chart, 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. |
401 | UNAUTHORIZED | No 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. |
402 | INSUFFICIENT_CREDITS | The 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. |
404 | NOT_FOUND | A wrong path, or a job id that does not belong to this token. |
409 | CONFLICT | An 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). |
429 | RATE_LIMITED | Too 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:
| What | What happens |
|---|---|
event: error on /run-stream | The 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 s | The 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 task field: two lanes
task routes the run, and it takes exactly two values. 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
buildInput turns any lane that is not "audit" or "schema"
into "audit"), so a typo silently gets you an audit: always send one of the two values.
task | What it does | verdict | Use it when |
|---|---|---|---|
"audit" | Reviews the chart the way a chart reviewer does before helm package: says what the chart deploys, confirms or dismisses each page flag, adds what text rules cannot see, gives the verbatim evidence and the whole corrected file for each finding (listed in the order to apply them), and orders the fixes into a plan — what makes helm lint and helm template pass first, then what breaks upgrades, then secrets and hardening, then pinning, then hygiene. | unsafe | needs_work | ready | Before packaging or upgrading: will it lint, render and upgrade, is it safe by default, and what should change first. |
"schema" | Writes the chart’s values.schema.json (draft-07) from values.yaml and how the templates use each value — every key typed and described, enum, minimum and pattern only where the chart or Kubernetes fixes the allowed values — plus a values reference (one row per key) and the keys whose type or shape it could not tell. | complete | partial | When you want Helm to reject a bad -f values.yaml at install time, and a values table for the README. |
The page chains the two: after an audit, “Apply every fix and write the schema” puts
every audit fix into the paste, switches to the schema lane on that fixed paste and adds an
audit_handoff string (below) with the audit’s verdict, headline, the files it
changed and its findings. Over the API that is a second run with task: "schema", the
fixed paste as chart, facts recomputed from the fixed paste, and that
string. The page drops the handoff as soon as the paste is edited.
Guards the page applies before a run and the API does not: it refuses to run the schema lane when
the paste has no values.yaml (“The schema lane needs values.yaml - paste it (with
a # file: values.yaml line) so there is something to describe.”), it warns before an audit
with no template in the paste (“the audit can only review Chart.yaml and values.yaml”),
and it warns when part of Chart.yaml or values.yaml did not read as YAML.
Input fields
This is exactly what the page sends (helmkit.js buildInput(lane, A, note,
handoff), passed through mustBeObject; app.js run),
and what input-schema.json declares. The platform’s input schema is scalar-only,
so every value is a string, in this order:
| Field | Type | Required | Meaning |
|---|---|---|---|
task | string | yes | "audit" or "schema" (the lanes above). |
chart | string | yes | The pasted chart, each file introduced by a # file: <path> line; line 1 is its first line. Every line in an audit reply is counted in this text. A large paste is cut on whole files (below). |
facts | string | yes | The page’s free check, Helmkit.facts(A), JSON-encoded into a string: JSON.stringify(facts) in JavaScript, json.dumps(facts) in Python, and so on. Sending the object itself is a 400. The model is told to parse it. |
note | string | no | What the engineer says: the goal, a constraint (“we still support Kubernetes 1.21”), a question. The page trims it, cuts it at 2000 characters and sends it only when it is not blank. It is context, not evidence and not instructions: the model tests every claim in it against chart and answers it in summary. |
audit_handoff | string | no | schema lane only: a JSON string built from an audit reply on this chart (recon.js schemaHandoff, below). buildInput drops it for the audit lane. |
retry_note | string | no | Sent only by the page’s automatic reformat retry: when a reply does not parse as JSON (even after the page tries to close a cut-off object), the page runs once more with “Your previous reply was not the single valid JSON object the instructions require (...). Reply again with ONLY the JSON object for task '<lane>' - no prose, no code fences; every array present (empty arrays where there is nothing to say).” A normal call never sends it. |
mustBeObject turns the input into a flat object of strings and drops empty values,
which is why facts and audit_handoff travel as strings and why an empty
note is simply absent.
The chart string
chart is the paste itself. A line # file: <path> starts each file,
and a --- line just before it only separates files. Files dropped on the page (or a
dropped chart folder) are joined that way by Helmkit.joinFiles: each file’s text,
trailing whitespace removed, under its # file: line, joined by --- lines,
ending in a newline — with Chart.yaml first, then values.yaml,
values.schema.json, the .tpl helpers, and the rest by path. A paste with no
# file: line is read as one file, guessed from its content. The page normalises line
endings to \n. Send the text you want the reply to quote: every file in an
audit reply must be a path from facts.files, every line must
fall inside that file, and every evidence must be copied from it.
The page reads up to 400 000 characters and 200 files, and sends at most 60 000 characters
(Helmkit.sentChart): past that it always keeps Chart.yaml,
values.yaml and values.schema.json, then the helpers, requirements and lock
files, then templates in paste order until the budget is spent, and ends the string with one line
naming what it left out,
# [chartplumb: N files not sent: templates/a.yaml, templates/b.yaml]
(the model is told never to quote or rewrite what was not sent), with a sentence in
facts.notes. facts is still computed from the whole paste. Both worked
examples below are small, so their chart is the paste unchanged.
The facts string
facts is optional to compute but required to send. It is produced by the page’s
free check: Helmkit.analyze splits the paste into files and runs the rules, and
Helmkit.facts(A) turns that analysis into the object below (it does not depend on the
lane: an audit and a schema run of the same paste carry the same facts). It is how the model knows
which flags to answer, which file paths to name, and which values to describe. Its top-level keys,
in the order built:
| Key | Contents |
|---|---|
chart | From Chart.yaml: name, version, app_version ((not set) when missing), api_version, type (application by default) and dependencies[] (name, version, repository, condition, alias) — or the string (Chart.yaml not pasted). |
files | Every file: path (use it verbatim), kind (chart, values, schema, template, helpers, notes, overlay, requirements, lock, subchart, other), line and end_line (the file’s content, without its # file: line). |
values, values_truncated | Every key of values.yaml, maps and lists included (up to 400): path (dotted), type (string, integer, number, boolean, object, array, null), default (shortened: {...}, [...], strings JSON-quoted) and line — or (values.yaml not pasted). values_truncated is present only past 400 keys. |
references | The .Values paths the templates read (once per path and file, up to 300): path, file, line, status — defined, missing (renders empty), missing_parent (the render fails), guarded, dash_in_path, no_dollar_in_range / no_dollar_in_with, or unknown when values.yaml was not pasted. |
defines, includes_undefined | Every define/block (name, file, line) and the names included but defined nowhere in the paste. |
workloads | Every rendered object: file, kind, api_version ((templated) when it is a template action), line. |
schema | absent, present or present, does not parse. |
flags | Every flag the page raised: uid (F1, F2, ... in severity then line order), id (HC-01..HC-29), sev (high | medium | low | info), file, files (only on a flag that aggregates several files: every file it covers), line, text and detail. An audit must answer every non-info flag by its uid. |
verdict_candidate | The audit verdict the flags point at (the page’s own reading): unsafe with any high flag, needs_work with any medium, otherwise ready. |
notes | Plain sentences about what was not read or not sent, and Chart.yaml/values.yaml lines that did not read as YAML. |
The simplest source is helmkit.js itself: the page loads it from
/helmkit.js, and it also runs in Node (it sets module.exports).
This script writes the two files the samples read, <stem>-chart.yaml and
<stem>-facts.json; for both worked examples it reproduces the page’s
chart and facts exactly (the samples compact the facts file back into
the page’s string byte for byte):
// build-facts.js -- run: node build-facts.js worker.yaml worker (helmkit.js saved next to it)
const fs = require("fs");
const Helmkit = require("./helmkit.js");
const [paste, stem] = process.argv.slice(2);
const A = Helmkit.analyze(fs.readFileSync(paste, "utf8"));
if (!A.ok) { console.error(A.errors.join("\n")); process.exit(1); }
fs.writeFileSync(stem + "-chart.yaml", Helmkit.sentChart(A).text); // what the page sends as chart
fs.writeFileSync(stem + "-facts.json", JSON.stringify(Helmkit.facts(A), null, 2)); // what it sends, compacted, as facts
console.log(A.files.length + " files; " + A.flags.length + " flags; verdict_candidate " + A.candidate);
For the worker example it prints 7 files; 8 flags; verdict_candidate needs_work; for
the links example, 6 files; 1 flags; verdict_candidate ready. Starting from a chart
directory rather than a paste, join its files first the way a dropped folder is joined (paths
relative to the directory, as in the example pastes):
// join-chart.js -- run: node join-chart.js ./queue-worker > worker.yaml (helmkit.js next to it)
const fs = require("fs"), path = require("path");
const Helmkit = require("./helmkit.js");
const root = process.argv[2];
const walk = (d) => fs.readdirSync(d, { withFileTypes: true }).flatMap((e) =>
e.isDirectory() ? walk(path.join(d, e.name)) : [path.relative(root, path.join(d, e.name)).split(path.sep).join("/")]);
const rank = (p) => { const b = p.split("/").pop(); return b === "Chart.yaml" ? 0 : b === "values.yaml" ? 1 : b === "values.schema.json" ? 2 : /\.tpl$/.test(b) ? 3 : 4; };
const files = walk(root).filter((p) => /\.(ya?ml|json|tpl|txt)$/i.test(p) || p.split("/").pop() === "Chart.lock")
.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
process.stdout.write(Helmkit.joinFiles(files.map((p) => ({ path: p, text: fs.readFileSync(path.join(root, p), "utf8") }))));
Without the page’s check. The platform only checks that facts
is a string, so a minimal object works — "{}", or a hand-built one such as
{"flags": [], "notes": ["No page check was run; read the chart directly."]}
— and the model still reads chart. But you lose what the check adds: with no
flags there is nothing to confirm or dismiss, with no files there are no
paths to name files by, and with no values the schema lane has no list of keys to
describe one by one. Build it with helmkit.js as above when you can.
The audit_handoff string
Only for task: "schema", and only for the chart the audit was about with the
audit’s fixes applied. The page builds both from the normalised audit reply
res (recon.js normalize, below) and the analysis
A of the audited paste, in recon.js schemaHandoff:
const ap = Recon.applyAll(A, res.findings) || { text: A.text, replaced: [], added: [] }; // every fix, in order
JSON.stringify({ from: "audit", verdict: res.verdict, headline: res.headline, applied: ap.replaced.concat(ap.added),
findings: res.findings.map(f => ({ id: f.id, file: f.file, severity: f.severity, title: f.title, flags: f.flags })) })
// ap.text is the fixed paste: it becomes chart, and facts is recomputed from it
That is {"from": "audit", "verdict", "headline", "applied": [paths], "findings": [{id, file,
severity, title, flags}]}. applyAll takes the findings that carry a
fix, in the order listed, and puts each fix in place of the pasted file it names
(Helmkit.matchFile tolerates a different chart directory in front of the path), or adds
it under a new # file: line when the paste lacks it; a later fix of the same file
replaces an earlier one. The schema then describes the fixed values. It is a previous reply, not
evidence: the model checks it against chart.
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
must ground everything in chart (what lives outside the paste — files not pasted,
the cluster and its Kubernetes version, a subchart’s values — goes into
questions), and it must never write a secret value: when the paste holds a credential
it says to rotate it, quotes the key with *** for the value, and never invents a new
credential. In the audit lane every page flag whose sev is not info is
answered exactly once in flag_answers, and an info flag is never reported as a finding.
A flag that lists several files is one flag, fixed only when every file it lists is
fixed.
task: "audit"
task:"audit".verdict:unsafe(any finding is high) |needs_work(the worst is medium) |ready(nothing above low; low findings and questions do not stop a release).headline: one sentence.chart: one or two sentences — what the chart deploys (workloads, services, ingress, secrets, dependencies) and how it is configured.findings[]:{id, flags[], file, line, severity, title, evidence, why, fix}.idis a catalogue id (HC-01..HC-28) orother;flagslists the pageuids it confirms ([]for something the page did not flag);fileis apathfromfacts.files, copied exactly — the file the fix changes (a new path such astemplates/NOTES.txtorvalues.schema.json, withline0, when the fix adds a file);lineis the line ofchartwith the offending text, inside that file’sline..end_line;severityishigh(helm lint, helm template or the API server rejects it, an upgrade fails, the chart ships a secret, or the pods run privileged) |medium(it installs but renders wrong or empty, is unpinned, or runs unhardened by default) |low(hygiene);evidenceis one to three lines copied verbatim fromchart(two facts that are apart are quoted each on its own line, in paste order;""for a file the paste lacks);fixis the whole corrected file — every line of it, without the# file:line, spaces only — and""only for a low finding or a secret that must be rotated outside the chart.- Findings are listed in the order to apply them (the order of
plan): each fix is written with the earlier fixes to the same file already in it, and no fix may leave the chart with a new high flag even before a later fix lands. flag_answers[]:{flag, status, reason}, exactly one per non-info page flag,statusconfirmed(a finding lists that uid) |dismissed(with the reason).plan[]: the fixes in the order to apply them.questions[](only what the paste cannot answer) andsummary(two to four sentences, answeringnoteif one was sent).
task: "schema"
task:"schema".verdict:complete(openis empty) |partial.headline: one sentence.schema: the JSON object Helm reads asvalues.schema.json, draft-07 ("$schema": "http://json-schema.org/draft-07/schema#", roottype: object). It describes every key of values.yaml: nested maps astype: objectwithproperties; maps users fill with their own keys (podAnnotations,nodeSelector) astype: objectwithadditionalPropertiesand noproperties; lists astype: arraywithitems; each property with adescription.enum,minimum,patternand the like appear only where the chart or Kubernetes fixes the allowed values, never narrower than the defaults;requiredarrays name only keys values.yaml sets or the templates already fail without; neveradditionalProperties: falseat the root or on a map a subchart or users extend. The chart’s own defaults must pass it.values[]:{path, type, default, required, description}, one entry per key of values.yaml (facts.values, samepath, same order), typed as the schema types it, the default as it appears (shortened), and a one-sentence description taken from how the templates use the value (a key no template reads says so).open[]:{path, reason}for every key whose type or allowed shape cannot be told from the chart.questions[]andsummary, as in the audit lane; with anaudit_handoff, the schema describes the fixed values.
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 schema for schema, a
findings array for audit). An unknown verdict shows as
needs_work or partial and counts as a disagreement; an unknown
severity becomes medium and counts as one too; ids like HC7
become HC-07; flags keeps only F<n> values; findings
with no file, evidence and title are dropped; file may also arrive as
path; a schema sent as a JSON string is parsed; questions is
capped at 8 and plan at 30. An audit reply carries 8 sections (verdict, headline,
chart, findings, flag_answers, plan, questions, summary) and a schema reply 7 (verdict, headline,
schema, values, open, questions, summary), which is what the page counts when it recovers a partial
reply.
Worked examples
Both are examples the page ships (the example buttons on the app page; the full
pastes are in /example.js, ids worker and
links), built exactly as the page builds them:
Helmkit.mustBeObject(Helmkit.buildInput(lane, Helmkit.analyze(text), note)), with a
real model reply for each. Long values are trimmed for display, and each excerpt says what it leaves
out; what is shown is verbatim. Neither paste holds a secret.
Audit: a queue worker chart a second team wants
Seven files: Chart.yaml (queue-worker 2.4.1, appVersion
"3.2.0"), values.yaml, templates/_helpers.tpl, a Deployment,
a Secret, a pre-install/pre-upgrade migration Job hook and NOTES.txt. The page’s
free check raises 8 flags (4 medium, 4 low); its own reading of the verdict is
needs_work. The engineer’s note: “A second team wants to install this in
their own namespace. What should we fix first?”
The chart string, an excerpt: its first 8 lines and lines 64-103
(templates/deployment.yaml), with the rest marked; the whole string is 3,870
characters, 143 lines ending in a newline. Save the full worker paste as
worker.yaml and run node build-facts.js worker.yaml worker:
# file: Chart.yaml
apiVersion: v2
name: queue-worker
description: Background workers that drain the Ostravane Labs job queue
type: application
version: 2.4.1
appVersion: "3.2.0"
---
... [lines 9-63 not shown: values.yaml and templates/_helpers.tpl]
# file: templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "fullname" . }}
namespace: workers
labels:
{{- include "queue-worker.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "queue-worker.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "queue-worker.selectorLabels" . | nindent 8 }}
spec:
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
nodeSelector: {{ toYaml .Values.nodeSelector }}
tolerations:
{{- toYaml .Values.tolerations | nindent 8 }}
containers:
- name: worker
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
args: ["--queue", {{ .Values.queue.name | quote }}, "--concurrency", {{ .Values.queue.concurrency | quote }}]
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
env:
- name: QUEUE_URL
value: {{ .Values.queue.url | quote }}
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "fullname" . }}-redis
key: password
resources:
{{- toYaml .Values.resources | nindent 12 }}
... [lines 104-143 not shown: templates/secret.yaml, templates/migrate-job.yaml, templates/NOTES.txt]
The facts object, decoded — an excerpt: all 7 files, 4 of the 29
values, 2 of the 20 references, and every one of the 8 flags. The full string is 7,684 characters;
build it with build-facts.js (it lands in worker-facts.json):
{
"chart": {
"name": "queue-worker",
"version": "2.4.1",
"app_version": "3.2.0",
"api_version": "v2",
"type": "application",
"dependencies": []
},
"files": [
{
"path": "Chart.yaml",
"kind": "chart",
"line": 2,
"end_line": 7
},
{
"path": "values.yaml",
"kind": "values",
"line": 10,
"end_line": 46
},
{
"path": "templates/_helpers.tpl",
"kind": "helpers",
"line": 49,
"end_line": 62
},
{
"path": "templates/deployment.yaml",
"kind": "template",
"line": 65,
"end_line": 103
},
{
"path": "templates/secret.yaml",
"kind": "template",
"line": 106,
"end_line": 114
},
{
"path": "templates/migrate-job.yaml",
"kind": "template",
"line": 117,
"end_line": 140
},
{
"path": "templates/NOTES.txt",
"kind": "notes",
"line": 143,
"end_line": 143
}
],
"values": [
{
"path": "image.tag",
"type": "string",
"default": "\"\"",
"line": 14
},
{
"path": "queue",
"type": "object",
"default": "{...}",
"line": 17
},
{
"path": "queue.name",
"type": "string",
"default": "\"jobs-default\"",
"line": 18
},
{
"path": "queue.concurrency",
"type": "integer",
"default": "8",
"line": 19
}
],
"references": [
{
"path": "queue.name",
"file": "templates/deployment.yaml",
"line": 91,
"status": "defined"
},
{
"path": "queue.url",
"file": "templates/deployment.yaml",
"line": 96,
"status": "missing"
}
],
"defines": [
{
"name": "fullname",
"file": "templates/_helpers.tpl",
"line": 49
},
{
"name": "queue-worker.selectorLabels",
"file": "templates/_helpers.tpl",
"line": 53
},
{
"name": "queue-worker.labels",
"file": "templates/_helpers.tpl",
"line": 58
}
],
"includes_undefined": [],
"workloads": [
{
"file": "templates/deployment.yaml",
"kind": "Deployment",
"api_version": "apps/v1",
"line": 66
},
{
"file": "templates/secret.yaml",
"kind": "Secret",
"api_version": "v1",
"line": 107
},
{
"file": "templates/migrate-job.yaml",
"kind": "Job",
"api_version": "batch/v1",
"line": 119
}
],
"schema": "absent",
"flags": [
{
"uid": "F1",
"id": "HC-20",
"sev": "medium",
"file": "templates/deployment.yaml",
"line": 69,
"text": "metadata.namespace is hardcoded to workers",
"detail": "helm install -n <ns> no longer decides where this lands, and helm uninstall can miss it. Use {{ .Release.Namespace }} or leave it out."
},
{
"uid": "F2",
"id": "HC-10",
"sev": "medium",
"file": "templates/deployment.yaml",
"line": 84,
"text": "toYaml of .Values.nodeSelector without nindent",
"detail": "toYaml writes several lines; only the first gets this line's indentation, so the rest lands at column 0 and the manifest breaks as soon as the value is a map or list. Write {{- toYaml ... | nindent N }} on its own line."
},
{
"uid": "F3",
"id": "HC-07",
"sev": "medium",
"file": "templates/deployment.yaml",
"line": 96,
"text": ".Values.queue.url is not in values.yaml",
"detail": "It renders as an empty string, silently. Give it a default in values.yaml (documented), or use default/required."
},
{
"uid": "F4",
"id": "HC-15",
"sev": "medium",
"file": "templates/secret.yaml",
"line": 114,
"text": "Secret generated with a random function and no lookup",
"detail": "Every helm upgrade renders a new random value, so the password changes under the running app. Read the existing Secret with lookup first, or require the value."
},
{
"uid": "F5",
"id": "HC-24",
"sev": "low",
"file": "values.yaml",
"line": 10,
"text": "no values.schema.json",
"detail": "Without it Helm accepts any value - a typo in -f values or a string where a number belongs renders a broken manifest instead of failing at install. The schema lane writes one."
},
{
"uid": "F6",
"id": "HC-25",
"sev": "low",
"file": "templates/_helpers.tpl",
"line": 49,
"text": "template name \"fullname\" not prefixed with the chart name",
"detail": "Template names are global across a chart and its subcharts; the Helm convention is \"queue-worker.<name>\" so a dependency's define cannot replace yours."
},
{
"uid": "F7",
"id": "HC-19",
"sev": "low",
"file": "templates/deployment.yaml",
"line": 66,
"text": "Deployment has no liveness or readiness probe",
"detail": "Kubernetes sends traffic before the app is ready and never restarts a hung one."
},
{
"uid": "F8",
"id": "HC-27",
"sev": "low",
"file": "templates/migrate-job.yaml",
"line": 123,
"text": "Job hook without helm.sh/hook-delete-policy",
"detail": "The hook object stays after it ran, and the next upgrade fails with already exists. Add before-hook-creation (and hook-succeeded)."
}
],
"verdict_candidate": "needs_work",
"notes": []
}
The request body the page sends, trimmed for display: the full body is 13,022 characters of compact JSON, and the two long strings are cut here after their first 160 characters, with the cut marked inside the string. The step 4 samples build the untrimmed body from the two saved files.
{
"task": "audit",
"chart": "# file: Chart.yaml\napiVersion: v2\nname: queue-worker\ndescription: Background workers that drain the Ostravane Labs job queue\ntype: application\nversion: 2.4.1\nappVer ... [trimmed for display: 160 of 3,870 characters shown]",
"facts": "{\"chart\":{\"name\":\"queue-worker\",\"version\":\"2.4.1\",\"app_version\":\"3.2.0\",\"api_version\":\"v2\",\"type\":\"application\",\"dependencies\":[]},\"files\":[{\"path\":\"Chart.yaml\" ... [trimmed for display: 160 of 7,684 characters shown]",
"note": "A second team wants to install this in their own namespace. What should we fix first?"
}
The page sends it with Idempotency-Key: chartplumb:audit:a3c411cd-32de:a1 (see
step 5). The reply, an excerpt: 3 of the 9 findings (the others are HC-10 and
HC-07 on templates/deployment.yaml, HC-27 on templates/migrate-job.yaml,
HC-24 on values.schema.json, HC-25 and other on
templates/_helpers.tpl), the first finding’s fix cut after 120
characters, 3 of the 8 flag answers (all 8 are confirmed), 3 of the 9 plan steps and
1 of the 2 questions:
{
"task": "audit",
"verdict": "needs_work",
"headline": "queue-worker renders and installs, but it hardcodes its namespace, mis-renders a bare nodeSelector, silently drops a required queue URL, and rotates its Redis password on every upgrade.",
"chart": "This chart deploys a Deployment (queue-worker, default 3 replicas) that drains a job queue and reads a Redis password from a Secret it also creates, plus a batch/v1 Job gated by a pre-install/pre-upgrade hook that runs the same image with a migrate command; there is no Service, Ingress, HPA, PDB or dependency, and no values.schema.json.",
"findings": [
{
"id": "HC-20",
"flags": [
"F1"
],
"file": "templates/deployment.yaml",
"line": 69,
"severity": "medium",
"title": "Deployment namespace is hardcoded to workers",
"evidence": "namespace: workers",
"why": "Any `helm install -n <their-namespace>` (or `helm install --namespace`) is silently overridden: the Deployment always lands in the `workers` namespace regardless of what the release targets, so a second team installing into their own namespace gets a Deployment in `workers` instead, and `helm uninstall` in their namespace won't find or remove it.",
"fix": "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: {{ include \"fullname\" . }}\n namespace: {{ .Release.Namespace }}\n ... [trimmed for display: 120 of 1,417 characters shown]"
},
{
"id": "HC-15",
"flags": [
"F4"
],
"file": "templates/secret.yaml",
"line": 114,
"severity": "medium",
"title": "Redis password Secret has no lookup, so it is regenerated on every upgrade",
"evidence": "password: {{ randAlphaNum 24 | b64enc | quote }}",
"why": "`randAlphaNum` is re-evaluated on every render. `helm upgrade` replaces the Secret's password with a brand-new random value while the running worker Pods still hold the old one in their environment, so they can no longer authenticate to Redis until they are restarted with the new Secret - a self-inflicted outage on every upgrade, not just when the chart changes.",
"fix": "apiVersion: v1\nkind: Secret\nmetadata:\n name: {{ include \"fullname\" . }}-redis\n labels:\n {{- include \"queue-worker.labels\" . | nindent 4 }}\ntype: Opaque\ndata:\n {{- $existing := lookup \"v1\" \"Secret\" .Release.Namespace (printf \"%s-redis\" (include \"fullname\" .)) }}\n {{- if $existing }}\n password: {{ index $existing.data \"password\" | quote }}\n {{- else }}\n password: {{ randAlphaNum 24 | b64enc | quote }}\n {{- end }}"
},
{
"id": "HC-19",
"flags": [
"F7"
],
"file": "templates/deployment.yaml",
"line": 66,
"severity": "low",
"title": "Deployment has no liveness or readiness probe",
"evidence": "kind: Deployment\n- name: worker",
"why": "Nothing in values.yaml or the container spec exposes a port, an HTTP path or a health command for this worker, so Kubernetes has no way to tell a hung or deadlocked worker Pod from a healthy one: it keeps routing work to it (via whatever pulls from the queue) and never restarts it. Adding a probe requires knowing what health check the worker process actually exposes, which is not in this paste - see questions.",
"fix": ""
}
],
"flag_answers": [
{
"flag": "F1",
"status": "confirmed",
"reason": "Line 69 hardcodes namespace: workers in templates/deployment.yaml; nothing derives it from .Release.Namespace."
},
{
"flag": "F4",
"status": "confirmed",
"reason": "templates/secret.yaml line 114 calls randAlphaNum with no lookup of the existing Secret, so the password changes on every render."
},
{
"flag": "F7",
"status": "confirmed",
"reason": "No livenessProbe or readinessProbe key appears anywhere in templates/deployment.yaml's container spec."
}
],
"plan": [
"Stop hardcoding the namespace in templates/deployment.yaml (HC-20) - this is what blocks the second team from installing into their own namespace; switch it to {{ .Release.Namespace }}.",
"Fix the missing nindent on the nodeSelector toYaml in templates/deployment.yaml (HC-10) so setting a real nodeSelector doesn't break the rendered manifest.",
"Make templates/deployment.yaml fail fast with `required` on .Values.queue.url (HC-07) instead of silently shipping an empty QUEUE_URL."
],
"questions": [
"What health check does the worker process expose (an HTTP path, a TCP port, or an exec command)? Nothing in the pasted templates or values.yaml exposes a port, so a liveness/readiness probe can't be written without guessing."
],
"summary": "Fix the hardcoded `namespace: workers` in templates/deployment.yaml first - that's the literal reason a second team's `helm install -n <their-ns>` won't land where they expect; pair it with the nodeSelector nindent fix since both are in the same file and both change how the chart renders. After that, make queue.url fail loudly instead of rendering empty, add the missing hook-delete-policy so re-running the migration Job doesn't break the next upgrade, and stop the Redis password from being regenerated on every upgrade. Nothing here is high severity, but four medium findings and no values.schema.json mean this chart needs work before it's handed to another team."
}
Held to the page’s own reconciliation (recon.js reconcile, on this
exact body and the full reply), this reply has 0 disagreements in 21 statements
checked. Every evidence quote is found in the file it names, every uid is a flag the page raised,
and the six fixes, put in place one after another in the listed order, raise no new high flag: the
five that answer a flag each clear it, and the sixth (other, a missing label) is
re-checked. With every fix applied (4 files replaced) the free check
raises 0 high, 0 medium and 3 low flags; the page notes, as information, that finding 6 names
values.schema.json, which the paste lacks, and that the confirmed low flags F5 (HC-24),
F6 (HC-25) and F7 (HC-19) are still raised — their findings carry no fix, as the contract
allows for a low finding.
To see that for yourself — what the page’s “Apply every fix to the paste”
does — put the fixes into the paste with helmkit.js and run the free check on the
result. Save the parsed reply as audit-reply.json:
// apply-fixes.js -- run: node apply-fixes.js worker.yaml audit-reply.json > fixed-chart.yaml (helmkit.js next to it)
const fs = require("fs");
const Helmkit = require("./helmkit.js");
const A = Helmkit.analyze(fs.readFileSync(process.argv[2], "utf8"));
const r = JSON.parse(fs.readFileSync(process.argv[3], "utf8")); // the parsed audit reply
const items = (r.findings || []).filter((f) => f.fix).map((f) => { // in the reply's order, as the page applies them
const m = Helmkit.matchFile(A, f.file);
return { file: m ? m.path : f.file, content: f.fix };
});
const out = Helmkit.applyFiles(A, items);
process.stdout.write(out.text); // the paste with every fixed file in place
const B = Helmkit.analyze(out.text); // the free check, run again
console.error("replaced " + out.replaced.join(", ") + (out.added.length ? "; added " + out.added.join(", ") : ""));
console.error(B.flags.filter((f) => f.sev !== "info").map((f) => f.id + " " + f.sev + " " + f.file + ": " + f.text).join("\n") || "no flags left");
For this reply it writes the 151-line fixed paste and reports:
replaced templates/deployment.yaml, templates/migrate-job.yaml, templates/secret.yaml, templates/_helpers.tpl
HC-24 low values.yaml: no values.schema.json
HC-25 low templates/_helpers.tpl: template name "fullname" not prefixed with the chart name
HC-19 low templates/deployment.yaml: Deployment has no liveness or readiness probe
To check one finding alone, pass [{ file, content: f.fix }] for just that finding
(what the page’s per-finding “Apply to the paste” does).
What the step 5 samples print for the full reply (the Python form; charged is whatever the run cost):
truncated: False charged: ...
needs_work - queue-worker renders and installs, but it hardcodes its namespace, mis-renders a bare nodeSelector, silently drops a required queue URL, and rotates its Redis password on every upgrade.
HC-20 templates/deployment.yaml 69 medium
HC-10 templates/deployment.yaml 84 medium
HC-07 templates/deployment.yaml 96 medium
HC-27 templates/migrate-job.yaml 123 low
HC-15 templates/secret.yaml 114 medium
HC-24 values.schema.json 0 low
HC-25 templates/_helpers.tpl 49 low
HC-19 templates/deployment.yaml 66 low
other templates/_helpers.tpl 61 low
answer F1 confirmed
answer F2 confirmed
answer F3 confirmed
answer F4 confirmed
answer F5 confirmed
answer F6 confirmed
answer F7 confirmed
answer F8 confirmed
Had you pressed “Apply every fix and write the schema” after this reply, the page would
put the six fixes into the paste, recompute facts from the fixed paste, and send the
schema lane (with the same note) with this audit_handoff, shown decoded and
trimmed to 3 of its 9 findings; in the body it is the compact
JSON.stringify of it (1,645 characters). That body is 13,710 characters and its key
would be chartplumb:schema:98abc1f2-358e:a1:
{
"from": "audit",
"verdict": "needs_work",
"headline": "queue-worker renders and installs, but it hardcodes its namespace, mis-renders a bare nodeSelector, silently drops a required queue URL, and rotates its Redis password on every upgrade.",
"applied": [
"templates/deployment.yaml",
"templates/migrate-job.yaml",
"templates/secret.yaml",
"templates/_helpers.tpl"
],
"findings": [
{
"id": "HC-20",
"file": "templates/deployment.yaml",
"severity": "medium",
"title": "Deployment namespace is hardcoded to workers",
"flags": [
"F1"
]
},
{
"id": "HC-10",
"file": "templates/deployment.yaml",
"severity": "medium",
"title": "nodeSelector toYaml has no nindent",
"flags": [
"F2"
]
},
{
"id": "HC-07",
"file": "templates/deployment.yaml",
"severity": "medium",
"title": "QUEUE_URL reads .Values.queue.url, which values.yaml never sets",
"flags": [
"F3"
]
}
]
}
Schema: values.schema.json and a values reference for a link shortener chart
Six files: Chart.yaml (link-shortener 0.9.2, appVersion
"1.4.0"), a values.yaml with 35 keys (maps included),
templates/_helpers.tpl, a Deployment, a Service and NOTES.txt. The free
check raises a single low flag (HC-24, no values.schema.json). This example has no audit before it,
so it carries no audit_handoff. The engineer’s note: “People keep setting
slugLength as a string and pullPolicy with a typo. Make the schema catch that.”
The chart string, an excerpt: its first 55 lines (Chart.yaml and
values.yaml, what the schema describes), with the templates marked; the whole string is 4,100
characters, 152 lines ending in a newline. Save it as links.yaml and run
node build-facts.js links.yaml links:
# file: Chart.yaml
apiVersion: v2
name: link-shortener
description: Short links for the Quillowen marketing team
type: application
version: 0.9.2
appVersion: "1.4.0"
---
# file: values.yaml
replicaCount: 1
image:
repository: registry.quillowen.example/marketing/link-shortener
tag: ""
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 8080
config:
baseUrl: https://go.quillowen.example
defaultRedirect: https://quillowen.example
slugLength: 7
analytics: true
allowedDomains:
- quillowen.example
- blog.quillowen.example
storage:
existingSecret: ""
redisUrl: redis://link-shortener-redis:6379/0
podAnnotations: {}
nodeSelector: {}
extraEnv: []
podSecurityContext:
runAsNonRoot: true
runAsUser: 1000
seccompProfile:
type: RuntimeDefault
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
resources:
requests:
cpu: 25m
memory: 48Mi
limits:
memory: 96Mi
... [lines 56-152 not shown: templates/_helpers.tpl, templates/deployment.yaml, templates/service.yaml, templates/NOTES.txt]
The facts object, decoded — an excerpt: all 6 files, 3 of the 35
values and 3 of the 20 references (the keys the note and the open list are about), both workloads
and the one flag. The full string is 5,904 characters (links-facts.json):
{
"chart": {
"name": "link-shortener",
"version": "0.9.2",
"app_version": "1.4.0",
"api_version": "v2",
"type": "application",
"dependencies": []
},
"files": [
{
"path": "Chart.yaml",
"kind": "chart",
"line": 2,
"end_line": 7
},
{
"path": "values.yaml",
"kind": "values",
"line": 10,
"end_line": 55
},
{
"path": "templates/_helpers.tpl",
"kind": "helpers",
"line": 58,
"end_line": 65
},
{
"path": "templates/deployment.yaml",
"kind": "template",
"line": 68,
"end_line": 135
},
{
"path": "templates/service.yaml",
"kind": "template",
"line": 138,
"end_line": 149
},
{
"path": "templates/NOTES.txt",
"kind": "notes",
"line": 152,
"end_line": 152
}
],
"values": [
{
"path": "image.pullPolicy",
"type": "string",
"default": "\"IfNotPresent\"",
"line": 15
},
{
"path": "config.slugLength",
"type": "integer",
"default": "7",
"line": 24
},
{
"path": "extraEnv",
"type": "array",
"default": "[]",
"line": 36
}
],
"references": [
{
"path": "image.pullPolicy",
"file": "templates/deployment.yaml",
"line": 97,
"status": "defined"
},
{
"path": "config.slugLength",
"file": "templates/deployment.yaml",
"line": 109,
"status": "defined"
},
{
"path": "extraEnv",
"file": "templates/deployment.yaml",
"line": 123,
"status": "defined"
}
],
"defines": [
{
"name": "link-shortener.fullname",
"file": "templates/_helpers.tpl",
"line": 58
},
{
"name": "link-shortener.selectorLabels",
"file": "templates/_helpers.tpl",
"line": 62
}
],
"includes_undefined": [],
"workloads": [
{
"file": "templates/deployment.yaml",
"kind": "Deployment",
"api_version": "apps/v1",
"line": 69
},
{
"file": "templates/service.yaml",
"kind": "Service",
"api_version": "v1",
"line": 139
}
],
"schema": "absent",
"flags": [
{
"uid": "F1",
"id": "HC-24",
"sev": "low",
"file": "values.yaml",
"line": 10,
"text": "no values.schema.json",
"detail": "Without it Helm accepts any value - a typo in -f values or a string where a number belongs renders a broken manifest instead of failing at install. The schema lane writes one."
}
],
"verdict_candidate": "ready",
"notes": []
}
The request body, trimmed the same way (the full body is 11,347 characters):
{
"task": "schema",
"chart": "# file: Chart.yaml\napiVersion: v2\nname: link-shortener\ndescription: Short links for the Quillowen marketing team\ntype: application\nversion: 0.9.2\nappVersion: \"1.4. ... [trimmed for display: 160 of 4,100 characters shown]",
"facts": "{\"chart\":{\"name\":\"link-shortener\",\"version\":\"0.9.2\",\"app_version\":\"1.4.0\",\"api_version\":\"v2\",\"type\":\"application\",\"dependencies\":[]},\"files\":[{\"path\":\"Chart.yam ... [trimmed for display: 160 of 5,904 characters shown]",
"note": "People keep setting slugLength as a string and pullPolicy with a typo. Make the schema catch that."
}
The page sends it with Idempotency-Key: chartplumb:schema:3f0fa05a-2c53:a1. The reply,
an excerpt: the schema root with 3 of its 11 top-level properties
(image, config and extraEnv; replicaCount,
service, storage, podAnnotations, nodeSelector,
podSecurityContext, securityContext and resources are left
out) and 3 of the 35 values rows; open, questions and
summary are whole:
{
"task": "schema",
"verdict": "partial",
"headline": "values.schema.json covers every values.yaml key and the chart's own defaults validate, but extraEnv's item shape can't be confirmed from the chart.",
"schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": [
"replicaCount",
"image",
"service",
"config",
"storage",
"podAnnotations",
"nodeSelector",
"extraEnv",
"podSecurityContext",
"securityContext",
"resources"
],
"properties": {
"image": {
"type": "object",
"required": [
"repository",
"tag",
"pullPolicy"
],
"properties": {
"repository": {
"type": "string",
"description": "Container image repository; combined with image.tag into \"<repository>:<tag>\" at templates/deployment.yaml:96."
},
"tag": {
"type": "string",
"description": "Container image tag; when empty, deployment.yaml:96 falls back to `.Chart.AppVersion` (\"1.4.0\")."
},
"pullPolicy": {
"type": "string",
"enum": [
"Always",
"IfNotPresent",
"Never"
],
"description": "Kubernetes imagePullPolicy for the app container, read at templates/deployment.yaml:97."
}
}
},
"config": {
"type": "object",
"required": [
"baseUrl",
"defaultRedirect",
"slugLength",
"analytics",
"allowedDomains"
],
"properties": {
"baseUrl": {
"type": "string",
"description": "Public base URL for short links; set as env var BASE_URL (deployment.yaml:105, quoted) and shown in templates/NOTES.txt:152."
},
"defaultRedirect": {
"type": "string",
"description": "Fallback redirect URL; set as env var DEFAULT_REDIRECT (deployment.yaml:107, quoted)."
},
"slugLength": {
"type": "integer",
"description": "Length of generated short-link slugs; set as env var SLUG_LENGTH (deployment.yaml:109), quoted to a string by the template."
},
"analytics": {
"type": "boolean",
"description": "Enables/disables analytics; set as env var ANALYTICS (deployment.yaml:111), quoted to a string by the template."
},
"allowedDomains": {
"type": "array",
"items": {
"type": "string"
},
"description": "Domains allowed for redirects; joined with commas into env var ALLOWED_DOMAINS (deployment.yaml:113)."
}
}
},
"extraEnv": {
"type": "array",
"items": {},
"description": "Extra container env entries appended verbatim via toYaml after the built-in env vars (deployment.yaml:123); the chart never reads an entry's fields."
}
}
},
"values": [
{
"path": "image.pullPolicy",
"type": "string",
"default": "\"IfNotPresent\"",
"required": true,
"description": "imagePullPolicy for the app container, read at deployment.yaml:97."
},
{
"path": "config.slugLength",
"type": "integer",
"default": "7",
"required": true,
"description": "Length of generated short-link slugs; set as env var SLUG_LENGTH (deployment.yaml:109), quoted to a string by the template."
},
{
"path": "extraEnv",
"type": "array",
"default": "[]",
"required": true,
"description": "Extra container env entries appended verbatim via toYaml after the built-in env vars (deployment.yaml:123); no template reads individual items."
}
],
"open": [
{
"path": "extraEnv",
"reason": "values.yaml sets it to [] and deployment.yaml:123 only does `{{- with .Values.extraEnv }}{{- toYaml . | nindent 12 }}{{- end }}`, dumping entries verbatim into the container's env list; no template reads an entry's fields, so the item shape (e.g. a Kubernetes EnvVar-style {name, value|valueFrom}) cannot be confirmed from the chart."
}
],
"questions": [],
"summary": "config.slugLength is typed integer (not string) and image.pullPolicy is constrained to Kubernetes' three valid pull policies, so both mistakes the note describes now fail schema validation before install rather than reaching the cluster. Every other values.yaml key is described and required to match how the templates read it, with enums/bounds added only where Kubernetes fixes them (Service type, port range, pullPolicy, seccompProfile.type) and never narrower than the chart's own defaults, which all validate. The one gap is extraEnv: the chart only toYaml's it verbatim into env, so its item shape is left open rather than guessed."
}
Held to the page’s reconciliation, this reply has 0 disagreements in 8
statements checked: the chart’s own values.yaml passes the schema, the schema
describes all 35 values, the values table has one entry per key typed as the schema types it, the
verdict (partial) fits the one open path, and put into the paste as
values.schema.json the free check raises no flag at all (0 high / 0 medium / 0 low).
To see that for yourself — what the page’s “Put it into the paste” does
— validate the chart’s defaults against the schema, put it into the paste and run the
free check again. Save the parsed reply as schema-reply.json:
// check-schema.js -- run: node check-schema.js links.yaml schema-reply.json > with-schema.yaml (helmkit.js next to it)
const fs = require("fs");
const Helmkit = require("./helmkit.js");
const A = Helmkit.analyze(fs.readFileSync(process.argv[2], "utf8"));
const r = JSON.parse(fs.readFileSync(process.argv[3], "utf8")); // the parsed schema reply
const errs = [];
Helmkit.validate(r.schema, A.values || {}, "", errs, r.schema); // the chart's own defaults against it
console.error(errs.length ? errs.map((e) => e.path + " " + e.msg).join("\n") : "values.yaml passes the schema");
const target = A.schemaFile ? A.schemaFile.path
: A.valuesFile ? A.valuesFile.path.replace(/values\.yaml$/, "values.schema.json") : "values.schema.json";
const out = Helmkit.applyFiles(A, [{ file: target, content: JSON.stringify(r.schema, null, 2) }]);
process.stdout.write(out.text); // the paste with the schema in it
const B = Helmkit.analyze(out.text); // the free check, run again
console.error(B.flags.filter((f) => f.sev !== "info").map((f) => f.id + " " + f.sev + " " + f.file + ": " + f.text).join("\n") || "no flags left");
For this reply it writes the 399-line paste with the schema in it and reports:
values.yaml passes the schema
no flags left
And it does what the note asked. Change slugLength: 7 to slugLength: "7"
and pullPolicy: IfNotPresent to pullPolicy: IfNotPresnt in
links.yaml, run it again, and the defaults now fail the schema:
image.pullPolicy "IfNotPresnt" is not one of "Always", "IfNotPresent", "Never"
config.slugLength is string ("7"), the schema wants integer
HC-23 high values.schema.json: the chart's own values.yaml fails values.schema.json (2 errors)
What the step 6 samples print for the full reply (the Python form), an excerpt — 8 of the 35 value lines:
truncated: False charged: ...
partial - values.schema.json covers every values.yaml key and the chart's own defaults validate, but extraEnv's item shape can't be confirmed from the chart.
value replicaCount integer 1 required
value image object {...} required
value image.repository string "registry.quillowen.example/marketing/link-shortener" required
value image.tag string "" required
value image.pullPolicy string "IfNotPresent" required
value service object {...} required
value service.type string "ClusterIP" required
value service.port integer 8080 required
... [27 more value lines, one per key of values.yaml]
open extraEnv - values.yaml sets it to [] and deployment.yaml:123 only does `{{- with .Values.extraEnv }}{{- toYaml . | nindent 12 }}{{- end }}`, dumping entries verbatim into the container's env list; no template reads an entry's fields, so the item shape (e.g. a Kubernetes EnvVar-style {name, value|valueFrom}) cannot be confirmed from the chart.
The page checks the reply against the chart; 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 chart and the handoff it sent). These checks are
client-side: a direct API caller gets none of them.
- Both lanes: the reply is the lane you asked for (a reply of the other lane, or one labelled with the other lane’s task, is a disagreement), it carries a valid verdict for that lane, and no secret from the paste is repeated anywhere in it (every credential-like value of six characters or more that the free check found in the paste, including a password inside a URL).
- Audit: every finding is anchored. Its
idis in the catalogue (HC-01..HC-29, orother; HC-29 is never a finding); itsseverityis high, medium or low; itsfileis a pasted file (or a new file the fix adds); itsevidenceis found in that file’s lines ofchart(whitespace collapsed; quoted lines that are apart must appear in order; a***matches what comes before it), and alineoutside that file is noted; every citeduidis a non-info flag the page raised. - Audit: every fix is put in place and re-checked. A fix must contain no tab and no
# file:line, and a fix for a.jsonfile must parse as JSON; a medium or high finding without a fix is a disagreement (except a credential, HC-13). The fixes are applied in the listed order, each on top of the ones before it, and after each one the free check runs again: the chart must still read, the flag the finding answers must be gone, no new high flag and no new YAML error may appear. - Audit: every flag is answered once, consistently. A
confirmedanswer needs a finding carrying that uid; adismissedone needs a reason and no finding carrying it (a dismissed high flag is pointed out); every non-info flag must be answered. The verdict must fit the severities (unsafeexactly when a finding is high;needs_workneeds a medium;readyallows no medium); there must be achartdescription, and aplanwhenever there is something to fix. - Audit: all the fixes together. With every fix applied the chart must still read, raise no new high flag, and no longer raise a confirmed medium or high flag.
- Schema: the schema is valid for Helm. It is a JSON object whose root is
type: objectwithoutadditionalProperties: false(a$schemathat is not draft-07, 2019-09 or 2020-12 is pointed out); the chart’s ownvalues.yamlpasses it (Helmkit.validate); every key infacts.valuesis described by it (keys under a free-form map are noted). - Schema: the values table matches. One row per key of values.yaml — none missing, none that values.yaml does not set — each with a description and a type the schema agrees with; every
openpath is a key of values.yaml with a reason;completeonly with nothing open,partialonly with something open. - Schema: put into the paste. The schema is written into the paste as
values.schema.json(in place of a pasted one, or next to values.yaml) and the free check runs again: it must raise neither HC-23 (does not parse, or the defaults fail it) nor HC-24.
If you call the API directly, apply your own checks — the evidence against your chart, every
flag answered, each fix put back and re-checked (apply-fixes.js above, then
helm lint, helm template and helm install --dry-run), the
schema validated against your defaults (check-schema.js, then helm lint,
which reads values.schema.json) — before you package or ship anything.
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}'
import hashlib, json, os, time, urllib.parse
import requests # pip install requests
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # step 2
class ApiError(Exception):
def __init__(self, status, code, message, details=None):
super().__init__(f"{status} {code}: {message}")
self.status, self.code, self.details = status, code, details
def api(method, path, body=None, headers=None):
h = {"Content-Type": "application/json"}
if TOKEN:
h["Authorization"] = "Bearer " + TOKEN
h.update(headers or {})
r = requests.request(method, BASE + path, headers=h,
data=None if body is None else json.dumps(body))
try:
env = r.json()
except ValueError:
env = {}
if not r.ok:
err = env.get("error") or {}
raise ApiError(r.status_code, err.get("code"), err.get("message") or r.reason, err.get("details"))
return env.get("data")
// chartplumb.mjs -- Node 18+ (global fetch), run with: node chartplumb.mjs
import { readFileSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // step 2
async function api(method, path, body, headers = {}) {
const h = { "Content-Type": "application/json", ...headers };
if (TOKEN) h.Authorization = "Bearer " + TOKEN;
const res = await fetch(BASE + path, {
method,
headers: h,
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error((json.error && json.error.message) || res.statusText);
err.status = res.status;
err.code = json.error && json.error.code;
err.details = json.error && json.error.details;
throw err;
}
return json.data;
}
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
)
const base = "https://api.skillsafe.ai/v1/app-api"
var token = os.Getenv("SKILLSAFE_TOKEN") // step 2
type apiError struct {
Status int
Code string
Message string
}
func (e *apiError) Error() string { return fmt.Sprintf("%d %s: %s", e.Status, e.Code, e.Message) }
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func newRequest(method, path string, body any, headers map[string]string) (*http.Request, error) {
var buf []byte
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
buf = b
}
req, err := http.NewRequest(method, base+path, bytes.NewReader(buf))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
for k, v := range headers {
req.Header.Set(k, v)
}
return req, nil
}
func errorFrom(res *http.Response, env envelope) error {
e := &apiError{Status: res.StatusCode, Message: http.StatusText(res.StatusCode)}
if env.Error != nil {
e.Code = env.Error.Code
if env.Error.Message != "" {
e.Message = env.Error.Message
}
}
return e
}
// api sends body as JSON and decodes the envelope's data into out (out may be nil).
func api(method, path string, body any, headers map[string]string, out any) error {
req, err := newRequest(method, path, body, headers)
if err != nil {
return err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
var env envelope
_ = json.NewDecoder(res.Body).Decode(&env)
if res.StatusCode < 200 || res.StatusCode > 299 {
return errorFrom(res, env)
}
if out != nil && len(env.Data) > 0 {
return json.Unmarshal(env.Data, out)
}
return nil
}
func main() {
// steps 2-6 go here; remove any import a step you skip no longer uses
_, _, _, _, _ = bufio.NewScanner, sha256.Sum256, hex.EncodeToString, url.PathEscape, strings.Index
_ = time.Second
}
// Chartplumb.java -- Java 17+, needs com.fasterxml.jackson.core:jackson-databind on the classpath
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.URLEncoder;
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.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class Chartplumb {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN"); // step 2
static final HttpClient HTTP = HttpClient.newHttpClient();
static final ObjectMapper JSON = new ObjectMapper();
static class ApiException extends Exception {
final int status;
final String code;
ApiException(int status, String code, String message) {
super(status + " " + code + ": " + message);
this.status = status;
this.code = code;
}
}
static HttpRequest.Builder request(String method, String path, Object body, Map<String, String> headers) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Content-Type", "application/json")
.method(method, body == null ? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body)));
if (token != null && !token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
return b;
}
static ApiException errorFrom(int status, JsonNode env) {
JsonNode err = env.path("error");
return new ApiException(status, err.path("code").asText(null), err.path("message").asText("HTTP " + status));
}
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
HttpResponse<String> res = HTTP.send(request(method, path, body, headers).build(), HttpResponse.BodyHandlers.ofString());
JsonNode env;
try { env = JSON.readTree(res.body()); } catch (Exception e) { env = JSON.createObjectNode(); }
if (env == null) env = JSON.createObjectNode();
if (res.statusCode() / 100 != 2) throw errorFrom(res.statusCode(), env);
return env.path("data");
}
public static void main(String[] args) throws Exception {
// steps 2-6 go here (makeInput from step 4 goes next to api())
}
}
require "digest"
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
$token = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # step 2
class ApiError < StandardError
attr_reader :status, :code
def initialize(status, code, message)
super("#{status} #{code}: #{message}")
@status = status
@code = code
end
end
def build_request(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{$token}" if $token && !$token.empty?
headers.each { |k, v| req[k] = v }
req.body = JSON.generate(body) unless body.nil?
[uri, req]
end
def api(method, path, body = nil, headers = {})
uri, req = build_request(method, path, body, headers)
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
env = (JSON.parse(res.body) rescue {})
env = {} unless env.is_a?(Hash)
unless res.is_a?(Net::HTTPSuccess)
err = env["error"] || {}
raise ApiError.new(res.code.to_i, err["code"], err["message"] || res.message)
end
env["data"]
end
<?php
// needs the curl extension
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"; // step 2
class ApiError extends Exception {
public $status;
public $apiCode;
public function __construct($status, $code, $message) {
parent::__construct("$status $code: $message");
$this->status = $status;
$this->apiCode = $code;
}
}
function headers_for(array $extra = []): array {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($extra as $k => $v) $h[] = "$k: $v";
return $h;
}
function api(string $method, string $path, $body = null, array $headers = []) {
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => headers_for($headers),
CURLOPT_RETURNTRANSFER => true,
]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
$raw = curl_exec($ch);
if ($raw === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$env = json_decode($raw);
if (!is_object($env)) $env = new stdClass();
if ($status < 200 || $status > 299) {
$e = $env->error ?? new stdClass();
throw new ApiError($status, $e->code ?? null, $e->message ?? "HTTP $status");
}
return $env->data ?? null;
}
// Program.cs -- .NET 6+ console app (implicit usings on) with top-level statements; steps 2-6 (with the MakeInput local function) go ABOVE this class
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
static class Api
{
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"; // step 2
public static readonly HttpClient Http = new HttpClient();
public class ApiException : Exception
{
public int Status;
public string? Code;
public ApiException(int status, string? code, string message) : base($"{status} {code}: {message}")
{
Status = status;
Code = code;
}
}
public static HttpRequestMessage Request(string method, string path, object? body = null, IDictionary<string, string>? headers = null)
{
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (body != null) req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
if (!string.IsNullOrEmpty(Token)) req.Headers.TryAddWithoutValidation("Authorization", "Bearer " + Token);
if (headers != null) foreach (var kv in headers) req.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
return req;
}
public static ApiException ErrorFrom(HttpResponseMessage res, JsonNode? env)
{
var err = env?["error"];
return new ApiException((int)res.StatusCode, err?["code"]?.GetValue<string>(),
err?["message"]?.GetValue<string>() ?? res.ReasonPhrase ?? "error");
}
public static async Task<JsonNode?> Call(string method, string path, object? body = null, IDictionary<string, string>? headers = null)
{
using var req = Request(method, path, body, headers);
using var res = await Http.SendAsync(req);
var text = await res.Content.ReadAsStringAsync();
JsonNode? env = null;
try { env = JsonNode.Parse(text); } catch (JsonException) { }
if (!res.IsSuccessStatusCode) throw ErrorFrom(res, env);
return env?["data"];
}
}
Step 2 — a token
Two kinds work:
-
Personal — needed for a run. Both lanes are metered in credits, so a run
needs a signed-in user (the page says “The audit run is metered, so it needs an
account.”) unless the estimate says
sponsor_enabled. Sign in and copy your token from /tokens.html, then export it asSKILLSAFE_TOKEN(the samples fall back to the placeholderYOUR_TOKEN). -
Guest — enough for
/meand/estimate.POST /guestwith{"slug": "chartplumb"}and noAuthorizationheader, exactly assdk.jsguest()does. The response’sdatacarriestokenandguest_id. Reuse a token you already hold rather than minting a new one each time.
# 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":"chartplumb"}' | jq -r '.data.token')
echo "guest token: ${TOKEN:0:12}..."
# personal token (needed for a run): set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if TOKEN in ("", "YOUR_TOKEN"):
TOKEN = "" # no Authorization header on /guest
guest = api("POST", "/guest", {"slug": "chartplumb"})
TOKEN = guest["token"]
print("guest", guest.get("guest_id"))
// personal token (needed for a run): paste it from /tokens.html into TOKEN and skip this
if (!TOKEN || TOKEN === "YOUR_TOKEN") {
TOKEN = ""; // no Authorization header on /guest
const guest = await api("POST", "/guest", { slug: "chartplumb" });
TOKEN = guest.token;
console.log("guest", guest.guest_id);
}
// inside main -- personal token (needed for a run): set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if token == "" {
var guest struct {
Token string `json:"token"`
GuestID string `json:"guest_id"`
}
if err := api("POST", "/guest", map[string]string{"slug": "chartplumb"}, nil, &guest); err != nil {
panic(err)
}
token = guest.Token
fmt.Println("guest", guest.GuestID)
}
// inside main -- personal token (needed for a run): set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if (token.isEmpty() || token.equals("YOUR_TOKEN")) {
token = ""; // no Authorization header on /guest
JsonNode guest = api("POST", "/guest", Map.of("slug", "chartplumb"), null);
token = guest.path("token").asText();
System.out.println("guest " + guest.path("guest_id").asText());
}
# personal token (needed for a run): set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if $token.empty? || $token == "YOUR_TOKEN"
$token = "" # no Authorization header on /guest
guest = api("POST", "/guest", { slug: "chartplumb" })
$token = guest["token"]
puts "guest #{guest["guest_id"]}"
end
// personal token (needed for a run): set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if ($TOKEN === "" || $TOKEN === "YOUR_TOKEN") {
$TOKEN = ""; // no Authorization header on /guest
$guest = api("POST", "/guest", ["slug" => "chartplumb"]);
$TOKEN = $guest->token;
echo "guest {$guest->guest_id}\n";
}
// top-level statements (above the Api class) -- personal token (needed for a run): set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if (Api.Token == "" || Api.Token == "YOUR_TOKEN")
{
Api.Token = ""; // no Authorization header on /guest
var guest = await Api.Call("POST", "/guest", new { slug = "chartplumb" });
Api.Token = guest!["token"]!.GetValue<string>();
Console.WriteLine($"guest {guest["guest_id"]}");
}
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'
me = api("GET", "/me")
print(me["subject_type"], me.get("subject_id"), me.get("credits"))
signed_in = me["subject_type"] == "user"
const me = await api("GET", "/me");
console.log(me.subject_type, me.subject_id, me.credits);
const signedIn = me.subject_type === "user";
var me struct {
SubjectType string `json:"subject_type"`
SubjectID any `json:"subject_id"`
Credits any `json:"credits"`
}
if err := api("GET", "/me", nil, nil, &me); err != nil {
panic(err)
}
fmt.Println(me.SubjectType, me.SubjectID, me.Credits)
signedIn := me.SubjectType == "user"
_ = signedIn
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.path("subject_type").asText() + " " + me.path("subject_id").asText() + " " + me.path("credits"));
boolean signedIn = me.path("subject_type").asText().equals("user");
me = api("GET", "/me")
puts [me["subject_type"], me["subject_id"], me["credits"]].join(" ")
signed_in = me["subject_type"] == "user"
$me = api("GET", "/me");
echo $me->subject_type, " ", $me->subject_id ?? "", " ", $me->credits ?? "", "\n";
$signedIn = $me->subject_type === "user";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me?["subject_type"]} {me?["subject_id"]} {me?["credits"]}");
var signedIn = me?["subject_type"]?.GetValue<string>() == "user";
Step 4 — build the input, then POST /estimate
First a small makeInput that does what buildInput does:
task, then chart as the pasted chart text, facts as a
compact JSON string, the note when it is not blank, and — for the schema lane only — the
audit_handoff string when you have one. The samples build the audit
input of the worker example from the two files build-facts.js wrote; step 6 reuses
makeInput for the links (schema) 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:
| Field | Meaning |
|---|---|
hold_credits | A 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. What you pay is the job’s charged_credits after settlement (step 5), normally less. A balance below the hold is allowed but the reply may be cut short (truncated: true). |
min_credits | The smallest balance that can start a run. Below it the page disables the run button, because the call would be a 402. |
sponsor_enabled | When true, a caller who is not signed in may run; otherwise the page asks them to sign in. |
warnings | Input warnings. They do not refuse the call; the page shows them next to the price, 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 chart and facts, so read them from the call rather than
hard-coding them.
# worker-chart.yaml and worker-facts.json: written by build-facts.js (see "The facts string").
# --rawfile keeps the paste byte for byte (jq 1.6+); jq -c turns the facts into ONE compact string.
BODY=$(jq -n --rawfile chart worker-chart.yaml --arg facts "$(jq -c . worker-facts.json)" \
--arg note "A second team wants to install this in their own namespace. What should we fix first?" \
'{task: "audit", chart: $chart, facts: $facts, note: $note}')
# No page check at hand? --arg facts '{}' is accepted as a string, but the reply then has no page flags to answer.
# Schema lane after an audit: task: "schema", the paste WITH the audit's fixes applied as chart (and its facts), plus
# --arg handoff "$(jq -c . handoff.json)" and audit_handoff: $handoff in the object.
api POST /estimate -d "$BODY" \
| jq '.error // (.data | {hold_credits, min_credits, sponsor_enabled, warnings})'
def make_input(task, chart_path, facts_path, note=None, handoff=None):
"""The body the page sends: task, chart (the chart paste), facts as a JSON STRING, an optional note,
and (schema lane only) the audit_handoff string."""
with open(chart_path, encoding="utf-8", newline="") as f:
chart = f.read() # the chart paste, verbatim
with open(facts_path, encoding="utf-8") as f:
facts = json.load(f) # the page's free check ({} if you have none)
inp = {
"task": task,
"chart": chart,
"facts": json.dumps(facts, separators=(",", ":"), ensure_ascii=False), # a STRING, like JSON.stringify
}
n = (note or "").strip()
if n:
inp["note"] = n[:2000] # optional; the page caps it at 2000 chars
if task == "schema" and handoff:
inp["audit_handoff"] = handoff # optional; a JSON string built from an audit reply
return inp
inp = make_input("audit", "worker-chart.yaml", "worker-facts.json",
"A second team wants to install this in their own namespace. What should we fix first?")
est = api("POST", "/estimate", inp)
print("hold", est.get("hold_credits"), "min", est.get("min_credits"),
"sponsored", est.get("sponsor_enabled"), "warnings", est.get("warnings") or [])
// The body the page sends: task, chart (the chart paste), facts as a JSON STRING, an optional note,
// and (schema lane only) the audit_handoff string.
function makeInput(task, chartPath, factsPath, note, handoff) {
const input = {
task,
chart: readFileSync(chartPath, "utf8"), // the chart paste, verbatim
facts: JSON.stringify(JSON.parse(readFileSync(factsPath, "utf8"))), // a STRING: the page's free check
};
const n = (note || "").trim();
if (n) input.note = n.length > 2000 ? n.slice(0, 2000) : n; // optional; max 2000 chars
if (task === "schema" && handoff) input.audit_handoff = handoff; // optional; a JSON string
return input;
}
const input = makeInput("audit", "worker-chart.yaml", "worker-facts.json",
"A second team wants to install this in their own namespace. What should we fix first?");
const est = await api("POST", "/estimate", input);
console.log("hold", est.hold_credits, "min", est.min_credits,
"sponsored", est.sponsor_enabled, "warnings", est.warnings || []);
// makeInput builds the body the page sends: task, chart (the chart paste), facts as a JSON STRING,
// an optional note and (schema lane only) the audit_handoff string.
makeInput := func(task, chartPath, factsPath, note, handoff string) map[string]any {
read := func(path string) []byte {
raw, err := os.ReadFile(path)
if err != nil {
panic(err)
}
return raw
}
var facts bytes.Buffer
if err := json.Compact(&facts, read(factsPath)); err != nil { // keeps key order; travels as a STRING
panic(err)
}
in := map[string]any{"task": task, "chart": string(read(chartPath)), "facts": facts.String()}
if n := []rune(strings.TrimSpace(note)); len(n) > 0 {
if len(n) > 2000 {
n = n[:2000] // optional; the page caps it at 2000 chars
}
in["note"] = string(n)
}
if task == "schema" && handoff != "" {
in["audit_handoff"] = handoff // optional; a JSON string built from an audit reply
}
return in
}
input := makeInput("audit", "worker-chart.yaml", "worker-facts.json",
"A second team wants to install this in their own namespace. What should we fix first?", "")
var est struct {
HoldCredits any `json:"hold_credits"`
MinCredits any `json:"min_credits"`
SponsorEnabled bool `json:"sponsor_enabled"`
Warnings []any `json:"warnings"`
}
if err := api("POST", "/estimate", input, nil, &est); err != nil {
panic(err)
}
fmt.Printf("hold %v min %v sponsored %v warnings %v\n", est.HoldCredits, est.MinCredits, est.SponsorEnabled, est.Warnings)
// ---- add to the Chartplumb class, next to api(): the body the page sends
static Map<String, Object> makeInput(String task, String chartPath, String factsPath, String note, String handoff) throws Exception {
Map<String, Object> in = new LinkedHashMap<>();
in.put("task", task);
in.put("chart", Files.readString(Path.of(chartPath))); // the chart paste, verbatim
in.put("facts", JSON.writeValueAsString(JSON.readTree(Files.readString(Path.of(factsPath))))); // a STRING, compact
String n = note == null ? "" : note.strip();
if (!n.isEmpty()) in.put("note", n.length() > 2000 ? n.substring(0, 2000) : n); // optional; max 2000 chars
if (task.equals("schema") && handoff != null && !handoff.isEmpty()) in.put("audit_handoff", handoff); // optional
return in;
}
// ---- inside main
Map<String, Object> input = makeInput("audit", "worker-chart.yaml", "worker-facts.json",
"A second team wants to install this in their own namespace. What should we fix first?", null);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println("hold " + est.path("hold_credits") + " min " + est.path("min_credits")
+ " sponsored " + est.path("sponsor_enabled").asBoolean(false) + " warnings " + est.path("warnings"));
# The body the page sends: task, chart (the chart paste), facts as a JSON STRING, an optional note,
# and (schema lane only) the audit_handoff string.
def make_input(task, chart_path, facts_path, note = nil, handoff = nil)
input = {
task: task,
chart: File.read(chart_path, encoding: "UTF-8"), # the chart paste, verbatim
facts: JSON.generate(JSON.parse(File.read(facts_path, encoding: "UTF-8"))) # a STRING, compact
}
n = note.to_s.strip
input[:note] = n[0, 2000] unless n.empty? # optional; max 2000 chars
input[:audit_handoff] = handoff if task == "schema" && handoff && !handoff.empty? # optional
input
end
input = make_input("audit", "worker-chart.yaml", "worker-facts.json",
"A second team wants to install this in their own namespace. What should we fix first?")
est = api("POST", "/estimate", input)
puts "hold #{est["hold_credits"]} min #{est["min_credits"]} " \
"sponsored #{est["sponsor_enabled"]} warnings #{(est["warnings"] || []).inspect}"
// The body the page sends: task, chart (the chart paste), facts as a JSON STRING, an optional note,
// and (schema lane only) the audit_handoff string.
function make_input(string $task, string $chartPath, string $factsPath, string $note = "", string $handoff = ""): array {
// decode to objects (not arrays) so an empty {} stays {} when re-encoded
$facts = json_encode(json_decode(file_get_contents($factsPath)), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$input = ["task" => $task, "chart" => file_get_contents($chartPath), "facts" => $facts]; // all STRINGS
$n = trim($note);
if ($n !== "") $input["note"] = mb_substr($n, 0, 2000); // optional; max 2000 chars
if ($task === "schema" && $handoff !== "") $input["audit_handoff"] = $handoff; // optional
return $input;
}
$input = make_input("audit", "worker-chart.yaml", "worker-facts.json",
"A second team wants to install this in their own namespace. What should we fix first?");
$est = api("POST", "/estimate", $input);
echo "hold ", $est->hold_credits ?? "-", " min ", $est->min_credits ?? "-",
" sponsored ", json_encode($est->sponsor_enabled ?? false),
" warnings ", json_encode($est->warnings ?? []), "\n";
// The body the page sends: task, chart (the chart paste), facts as a JSON STRING, an optional note,
// and (schema lane only) the audit_handoff string.
static Dictionary<string, string> MakeInput(string task, string chartPath, string factsPath, string? note = null, string? handoff = null)
{
var compact = new JsonSerializerOptions { Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
var input = new Dictionary<string, string>
{
["task"] = task,
["chart"] = File.ReadAllText(chartPath), // the chart paste, verbatim
["facts"] = JsonNode.Parse(File.ReadAllText(factsPath))!.ToJsonString(compact), // a STRING, compact
};
var n = (note ?? "").Trim();
if (n.Length > 0) input["note"] = n.Length > 2000 ? n[..2000] : n; // optional; max 2000 chars
if (task == "schema" && !string.IsNullOrEmpty(handoff)) input["audit_handoff"] = handoff; // optional
return input;
}
var input = MakeInput("audit", "worker-chart.yaml", "worker-facts.json",
"A second team wants to install this in their own namespace. What should we fix first?");
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"hold {est?["hold_credits"]} min {est?["min_credits"]} " +
$"sponsored {est?["sponsor_enabled"]} warnings {est?["warnings"]?.ToJsonString() ?? "[]"}");
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:
output.output— the model’s reply as a string: the JSON object from the output contract of the lane you asked for. Parse it yourself (from the first{to the last}). The samples print the verdict and headline, each finding’sid,file,lineandseverity, then each flag answer.truncated—truewhen the reply was cut short because the balance ran out. The page then closes the cut-off JSON, shows what arrived as a partial result (“N of 8 sections recovered” for an audit, of 7 for a schema), and says it was cut short by the available balance.charged_credits— the real cost of the run, as opposed to the estimate’shold_creditsreservation. The page re-reads/meafterwards for the new balance.error— on afailedjob.
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 schema of the same paste never share a key. The page uses
chartplumb:<lane>:<inputHash>:a<attempt>, where
<lane> is audit or schema,
<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
chartplumb:audit:a3c411cd-32de:a1 and
chartplumb:schema:3f0fa05a-2c53: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="chartplumb: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, findings: [(.findings // [])[] | {id, file, line, severity}], flag_answers: [(.flag_answers // [])[] | {flag, status}]}'
body = json.dumps(inp)
key = "chartplumb:audit:" + hashlib.sha256(body.encode()).hexdigest()[:16] + ":a1"
job_id = api("POST", "/run", inp, {"Idempotency-Key": key})["job_id"]
deadline = time.time() + 180 # 1 s interval, 180 s client-side limit
while True:
job = api("GET", "/jobs/" + urllib.parse.quote(job_id, safe=""))
if job["status"] in ("succeeded", "failed"):
break
if time.time() > deadline:
raise TimeoutError("job timed out")
time.sleep(1)
if job["status"] == "failed":
raise SystemExit(f"run failed: {job.get('error')}")
print("truncated:", job.get("truncated") is True, "charged:", job.get("charged_credits"))
text = (job.get("output") or {}).get("output") or ""
reply = json.loads(text[text.index("{"): text.rindex("}") + 1])
print(reply["verdict"], "-", reply["headline"])
for f in reply.get("findings", []):
print(f["id"], f["file"], f.get("line"), f["severity"])
for a in reply.get("flag_answers", []):
print("answer", a["flag"], a["status"])
const key = "chartplumb:audit:" +
createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16) + ":a1";
const { job_id } = await api("POST", "/run", input, { "Idempotency-Key": key });
let job;
const deadline = Date.now() + 180000; // 1 s interval, 180 s client-side limit
for (;;) {
job = await api("GET", "/jobs/" + encodeURIComponent(job_id));
if (job.status === "succeeded" || job.status === "failed") break;
if (Date.now() > deadline) throw new Error("job timed out");
await new Promise((r) => setTimeout(r, 1000));
}
if (job.status === "failed") throw new Error("run failed: " + JSON.stringify(job.error));
console.log("truncated:", job.truncated === true, "charged:", job.charged_credits);
const text = (job.output && job.output.output) || "";
const reply = JSON.parse(text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1));
console.log(reply.verdict, "-", reply.headline);
for (const f of reply.findings || []) console.log(f.id, f.file, f.line, f.severity);
for (const a of reply.flag_answers || []) console.log("answer", a.flag, a.status);
inputJSON, _ := json.Marshal(input)
sum := sha256.Sum256(inputJSON)
key := "chartplumb:audit:" + hex.EncodeToString(sum[:8]) + ":a1"
var started struct {
JobID string `json:"job_id"`
}
if err := api("POST", "/run", input, map[string]string{"Idempotency-Key": key}, &started); err != nil {
panic(err)
}
type jobT struct {
Status string `json:"status"`
Output *struct {
Output string `json:"output"`
} `json:"output"`
Truncated bool `json:"truncated"`
ChargedCredits any `json:"charged_credits"`
Error any `json:"error"`
}
var job jobT
deadline := time.Now().Add(180 * time.Second) // 1 s interval, 180 s client-side limit
for {
job = jobT{}
if err := api("GET", "/jobs/"+url.PathEscape(started.JobID), nil, nil, &job); err != nil {
panic(err)
}
if job.Status == "succeeded" || job.Status == "failed" {
break
}
if time.Now().After(deadline) {
panic("job timed out")
}
time.Sleep(time.Second)
}
if job.Status == "failed" {
panic(fmt.Sprintf("run failed: %v", job.Error))
}
fmt.Println("truncated:", job.Truncated, "charged:", job.ChargedCredits)
text := ""
if job.Output != nil {
text = job.Output.Output
}
var reply map[string]any
if a, b := strings.Index(text, "{"), strings.LastIndex(text, "}"); a >= 0 && b > a {
if err := json.Unmarshal([]byte(text[a:b+1]), &reply); err != nil {
panic(err)
}
}
fmt.Println(reply["verdict"], "-", reply["headline"])
findings, _ := reply["findings"].([]any)
for _, e := range findings {
f, _ := e.(map[string]any)
fmt.Println(f["id"], f["file"], f["line"], f["severity"])
}
answers, _ := reply["flag_answers"].([]any)
for _, e := range answers {
a, _ := e.(map[string]any)
fmt.Println("answer", a["flag"], a["status"])
}
byte[] digest = MessageDigest.getInstance("SHA-256").digest(JSON.writeValueAsBytes(input));
String key = "chartplumb:audit:" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
String jobId = api("POST", "/run", input, Map.of("Idempotency-Key", key)).path("job_id").asText();
JsonNode job;
long deadline = System.currentTimeMillis() + 180_000; // 1 s interval, 180 s client-side limit
while (true) {
job = api("GET", "/jobs/" + URLEncoder.encode(jobId, StandardCharsets.UTF_8), null, null);
String s = job.path("status").asText();
if (s.equals("succeeded") || s.equals("failed")) break;
if (System.currentTimeMillis() > deadline) throw new RuntimeException("job timed out");
Thread.sleep(1000);
}
if (job.path("status").asText().equals("failed")) throw new RuntimeException("run failed: " + job.path("error"));
System.out.println("truncated: " + job.path("truncated").asBoolean(false) + ", charged: " + job.path("charged_credits"));
String text = job.path("output").path("output").asText("");
JsonNode reply = JSON.readTree(text.substring(text.indexOf('{'), text.lastIndexOf('}') + 1));
System.out.println(reply.path("verdict").asText() + " - " + reply.path("headline").asText());
for (JsonNode f : reply.path("findings")) {
System.out.println(f.path("id").asText() + " " + f.path("file").asText() + " " + f.path("line") + " " + f.path("severity").asText());
}
for (JsonNode a : reply.path("flag_answers")) {
System.out.println("answer " + a.path("flag").asText() + " " + a.path("status").asText());
}
key = "chartplumb:audit:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job_id = api("POST", "/run", input, { "Idempotency-Key" => key })["job_id"]
deadline = Time.now + 180 # 1 s interval, 180 s client-side limit
job = nil
loop do
job = api("GET", "/jobs/#{URI.encode_www_form_component(job_id)}")
break if %w[succeeded failed].include?(job["status"])
raise "job timed out" if Time.now > deadline
sleep 1
end
raise "run failed: #{job["error"].inspect}" if job["status"] == "failed"
puts "truncated: #{job["truncated"] == true}, charged: #{job["charged_credits"]}"
text = (job["output"] || {})["output"].to_s
reply = JSON.parse(text[text.index("{")..text.rindex("}")])
puts "#{reply["verdict"]} - #{reply["headline"]}"
(reply["findings"] || []).each { |f| puts "#{f["id"]} #{f["file"]} #{f["line"].inspect} #{f["severity"]}" }
(reply["flag_answers"] || []).each { |a| puts "answer #{a["flag"]} #{a["status"]}" }
$key = "chartplumb:audit:" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$jobId = api("POST", "/run", $input, ["Idempotency-Key" => $key])->job_id;
$deadline = time() + 180; // 1 s interval, 180 s client-side limit
while (true) {
$job = api("GET", "/jobs/" . rawurlencode($jobId));
if (in_array($job->status, ["succeeded", "failed"], true)) break;
if (time() > $deadline) throw new RuntimeException("job timed out");
sleep(1);
}
if ($job->status === "failed") throw new RuntimeException("run failed: " . json_encode($job->error ?? null));
echo "truncated: ", json_encode(($job->truncated ?? false) === true), ", charged: ", $job->charged_credits ?? "-", "\n";
$text = $job->output->output ?? "";
$reply = json_decode(substr($text, strpos($text, "{"), strrpos($text, "}") - strpos($text, "{") + 1));
echo $reply->verdict, " - ", $reply->headline, "\n";
foreach ($reply->findings ?? [] as $f) echo $f->id, " ", $f->file, " ", json_encode($f->line ?? null), " ", $f->severity, "\n";
foreach ($reply->flag_answers ?? [] as $a) echo "answer ", $a->flag, " ", $a->status, "\n";
var digest = System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(input)));
var key = "chartplumb:audit:" + Convert.ToHexString(digest, 0, 8).ToLowerInvariant() + ":a1";
var started = await Api.Call("POST", "/run", input, new Dictionary<string, string> { ["Idempotency-Key"] = key });
var jobId = started!["job_id"]!.GetValue<string>();
JsonNode job;
var deadline = DateTime.UtcNow.AddSeconds(180); // 1 s interval, 180 s client-side limit
while (true)
{
job = (await Api.Call("GET", "/jobs/" + Uri.EscapeDataString(jobId)))!;
var s = job["status"]?.GetValue<string>();
if (s == "succeeded" || s == "failed") break;
if (DateTime.UtcNow > deadline) throw new TimeoutException("job timed out");
await Task.Delay(1000);
}
if (job["status"]?.GetValue<string>() == "failed") throw new Exception($"run failed: {job["error"]?.ToJsonString()}");
var truncated = job["truncated"] is JsonValue t && t.TryGetValue(out bool tb) && tb;
Console.WriteLine($"truncated: {truncated}, charged: {job["charged_credits"]}");
var text = job["output"]?["output"]?.GetValue<string>() ?? "";
var reply = JsonNode.Parse(text[text.IndexOf('{')..(text.LastIndexOf('}') + 1)])!;
Console.WriteLine($"{reply["verdict"]} - {reply["headline"]}");
foreach (var f in reply["findings"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"{f?["id"]} {f?["file"]} {f?["line"]?.ToJsonString() ?? "null"} {f?["severity"]}");
foreach (var a in reply["flag_answers"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"answer {a?["flag"]} {a?["status"]}");
Step 6 — POST /run-stream (Server-Sent Events, schema)
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 schema input of the
links 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:
| Event | Data | What the SDK does |
|---|---|---|
delta | {text} | Passes text to onDelta. |
job | job info | Passes it to onJob (the page uses it to advance its progress display). |
done | the final payload: {job_id, status, charged_credits, output} | Keeps it as the result. |
pending | a payload | Treated 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 row of the values reference
(path, type, default, required or optional), each open path
and each question, and write the schema object to values.schema.json
(what the page’s “values.schema.json” export writes: JSON.stringify(schema,
null, 2) and a newline). To check it the way the page’s “Put it into the
paste” does, put it into the chart as values.schema.json with
check-schema.js (under “Worked examples”) and run the free check on the
result. To write the schema of a chart you audited, send the paste with the audit’s fixes
applied and pass the handoff string as makeInput’s last argument.
# the schema worked example: links-chart.yaml and links-facts.json from build-facts.js
SBODY=$(jq -n --rawfile chart links-chart.yaml --arg facts "$(jq -c . links-facts.json)" \
--arg note "People keep setting slugLength as a string and pullPolicy with a typo. Make the schema catch that." \
'{task: "schema", chart: $chart, facts: $facts, note: $note}')
SKEY="chartplumb:schema:$(printf '%s' "$SBODY" | shasum -a 256 | cut -c1-16):a1"
# -N turns off buffering so frames print as they arrive; tee keeps them for the next lines
api POST /run-stream -N -H "Idempotency-Key: $SKEY" -d "$SBODY" | tee stream.txt
# frames look like:
# event: job
# data: {...}
#
# event: done
# data: {"job_id":"...","status":"...","charged_credits":...,"output":{"output":"{\"task\":\"schema\",...}"}}
# the final reply text, from the done (or pending) frame:
REPLY=$(awk '/^event:/{ev=$2} /^data:/ && (ev=="done" || ev=="pending"){sub(/^data: */,""); print}' stream.txt \
| jq -r '.output.output')
# an idempotent replay answers with a plain JSON envelope instead: REPLY=$(jq -r '.data.output.output' stream.txt)
printf '%s' "$REPLY" | jq '{verdict, headline, values: [(.values // [])[] | {path, type, default, required}], open, questions}'
printf '%s' "$REPLY" | jq '.schema' > values.schema.json # the page's "values.schema.json" export, 2-space indent
# the schema worked example: links-chart.yaml and links-facts.json from build-facts.js
# (for the schema of an audited chart, pass the fixed paste and handoff=json.dumps(...) built as in "The audit_handoff string")
schema_inp = make_input("schema", "links-chart.yaml", "links-facts.json",
"People keep setting slugLength as a string and pullPolicy with a typo. Make the schema catch that.")
schema_key = "chartplumb:schema:" + hashlib.sha256(json.dumps(schema_inp).encode()).hexdigest()[:16] + ":a1"
h = {"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN, "Idempotency-Key": schema_key}
done, failure, streamed = None, None, ""
with requests.post(BASE + "/run-stream", headers=h, data=json.dumps(schema_inp), stream=True) as r:
if "text/event-stream" not in r.headers.get("content-type", ""):
try:
env = r.json()
except ValueError:
env = {}
if not r.ok:
err = env.get("error") or {}
raise ApiError(r.status_code, err.get("code"), err.get("message") or r.reason)
done = env.get("data")
else:
event, data = "message", ""
for line in r.iter_lines(decode_unicode=True):
if line == "": # a blank line ends a frame
if data:
try:
payload = json.loads(data)
except ValueError:
payload = None
if event == "delta" and payload is not None:
streamed += payload.get("text") or ""
elif event == "job":
print("job", payload)
elif event in ("done", "pending"):
done = payload
elif event == "error":
failure = payload
event, data = "message", ""
elif line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
data += line[5:].strip()
if failure:
raise SystemExit(f"{failure.get('code')}: {failure.get('message')} (job {failure.get('job_id')})")
done = done or {}
full = (done.get("output") or {}).get("output") or streamed
print("truncated:", done.get("truncated") is True, "charged:", done.get("charged_credits"))
result = json.loads(full[full.index("{"): full.rindex("}") + 1])
print(result["verdict"], "-", result["headline"])
for v in result.get("values", []):
print("value", v["path"], v.get("type"), v.get("default"), "required" if v.get("required") is True else "optional")
for o in result.get("open", []):
print("open", o["path"], "-", o.get("reason"))
for q in result.get("questions", []):
print("question", q)
if isinstance(result.get("schema"), dict): # what the page's "values.schema.json" export writes
with open("values.schema.json", "w", encoding="utf-8") as f:
f.write(json.dumps(result["schema"], indent=2, ensure_ascii=False) + "\n")
// the schema worked example: links-chart.yaml and links-facts.json from build-facts.js
// (for the schema of an audited chart, pass the fixed paste and the audit_handoff string as the last argument)
const schemaInput = makeInput("schema", "links-chart.yaml", "links-facts.json",
"People keep setting slugLength as a string and pullPolicy with a typo. Make the schema catch that.");
const schemaKey = "chartplumb:schema:" +
createHash("sha256").update(JSON.stringify(schemaInput)).digest("hex").slice(0, 16) + ":a1";
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer " + TOKEN, "Idempotency-Key": schemaKey },
body: JSON.stringify(schemaInput),
});
let done = null, failure = null, streamed = "";
if (!(res.headers.get("content-type") || "").includes("text/event-stream")) {
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((json.error && json.error.message) || res.statusText);
done = json.data;
} else {
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
let idx;
while ((idx = buffer.indexOf("\n\n")) >= 0) {
const frame = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
let event = "message", dataStr = "";
for (const line of frame.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataStr += line.slice(5).trim();
}
if (!dataStr) continue;
let data;
try { data = JSON.parse(dataStr); } catch { continue; }
if (event === "delta") streamed += data.text || "";
else if (event === "job") console.log("job", data);
else if (event === "done" || event === "pending") done = data;
else if (event === "error") failure = data;
}
}
}
if (failure) throw new Error(`${failure.code}: ${failure.message} (job ${failure.job_id})`);
const full = (done && done.output && done.output.output) || streamed;
console.log("truncated:", !!(done && done.truncated), "charged:", done && done.charged_credits);
const result = JSON.parse(full.slice(full.indexOf("{"), full.lastIndexOf("}") + 1));
console.log(result.verdict, "-", result.headline);
for (const v of result.values || []) console.log("value", v.path, v.type, v.default, v.required === true ? "required" : "optional");
for (const o of result.open || []) console.log("open", o.path, "-", o.reason);
for (const q of result.questions || []) console.log("question", q);
if (result.schema && typeof result.schema === "object") // what the page's "values.schema.json" export writes
writeFileSync("values.schema.json", JSON.stringify(result.schema, null, 2) + "\n");
// the schema worked example: links-chart.yaml and links-facts.json from build-facts.js
// (for the schema of an audited chart, pass the fixed paste and the audit_handoff string as the last argument)
schemaInput := makeInput("schema", "links-chart.yaml", "links-facts.json",
"People keep setting slugLength as a string and pullPolicy with a typo. Make the schema catch that.", "")
schemaJSON, _ := json.Marshal(schemaInput)
schemaSum := sha256.Sum256(schemaJSON)
schemaKey := "chartplumb:schema:" + hex.EncodeToString(schemaSum[:8]) + ":a1"
sreq, err := newRequest("POST", "/run-stream", schemaInput, map[string]string{"Idempotency-Key": schemaKey})
if err != nil {
panic(err)
}
sres, err := http.DefaultClient.Do(sreq)
if err != nil {
panic(err)
}
defer sres.Body.Close()
var done, failure map[string]any
var streamed strings.Builder
if !strings.Contains(sres.Header.Get("Content-Type"), "text/event-stream") {
var env envelope
_ = json.NewDecoder(sres.Body).Decode(&env)
if sres.StatusCode < 200 || sres.StatusCode > 299 {
panic(errorFrom(sres, env))
}
_ = json.Unmarshal(env.Data, &done)
} else {
sc := bufio.NewScanner(sres.Body)
sc.Buffer(make([]byte, 64*1024), 16*1024*1024) // the done frame can be long
event, data := "message", ""
for sc.Scan() {
line := sc.Text()
switch {
case line == "": // a blank line ends a frame
if data != "" {
var p map[string]any
if json.Unmarshal([]byte(data), &p) == nil {
switch event {
case "delta":
if t, ok := p["text"].(string); ok {
streamed.WriteString(t)
}
case "job":
fmt.Println("job", p)
case "done", "pending":
done = p
case "error":
failure = p
}
}
}
event, data = "message", ""
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:"):
data += strings.TrimSpace(line[5:])
}
}
}
if failure != nil {
panic(fmt.Sprintf("%v: %v (job %v)", failure["code"], failure["message"], failure["job_id"]))
}
full := streamed.String()
if out, ok := done["output"].(map[string]any); ok {
if s, ok := out["output"].(string); ok && s != "" {
full = s
}
}
fmt.Println("truncated:", done["truncated"] == true, "charged:", done["charged_credits"])
var result map[string]any
if a, b := strings.Index(full, "{"), strings.LastIndex(full, "}"); a >= 0 && b > a {
if err := json.Unmarshal([]byte(full[a:b+1]), &result); err != nil {
panic(err)
}
}
fmt.Println(result["verdict"], "-", result["headline"])
values, _ := result["values"].([]any)
for _, e := range values {
v, _ := e.(map[string]any)
req := "optional"
if v["required"] == true {
req = "required"
}
fmt.Println("value", v["path"], v["type"], v["default"], req)
}
open, _ := result["open"].([]any)
for _, e := range open {
o, _ := e.(map[string]any)
fmt.Println("open", o["path"], "-", o["reason"])
}
questions, _ := result["questions"].([]any)
for _, q := range questions {
fmt.Println("question", q)
}
// what the page's "values.schema.json" export writes (Go sorts the keys; the page keeps the reply's order)
if schema, ok := result["schema"].(map[string]any); ok {
out, _ := json.MarshalIndent(schema, "", " ")
if err := os.WriteFile("values.schema.json", append(out, '\n'), 0o644); err != nil {
panic(err)
}
}
// the schema worked example: links-chart.yaml and links-facts.json from build-facts.js
// (for the schema of an audited chart, pass the fixed paste and the audit_handoff string as the last argument)
Map<String, Object> schemaInput = makeInput("schema", "links-chart.yaml", "links-facts.json",
"People keep setting slugLength as a string and pullPolicy with a typo. Make the schema catch that.", null);
byte[] schemaDigest = MessageDigest.getInstance("SHA-256").digest(JSON.writeValueAsBytes(schemaInput));
String schemaKey = "chartplumb:schema:" + HexFormat.of().formatHex(schemaDigest, 0, 8) + ":a1";
HttpRequest sreq = request("POST", "/run-stream", schemaInput, Map.of("Idempotency-Key", schemaKey)).build();
HttpResponse<Stream<String>> sres = HTTP.send(sreq, HttpResponse.BodyHandlers.ofLines());
JsonNode done = null, failure = null;
StringBuilder streamed = new StringBuilder();
if (!sres.headers().firstValue("content-type").orElse("").contains("text/event-stream")) {
JsonNode env = JSON.readTree(sres.body().collect(Collectors.joining("\n")));
if (env == null) env = JSON.createObjectNode();
if (sres.statusCode() / 100 != 2) throw errorFrom(sres.statusCode(), env);
done = env.path("data");
} else {
String event = "message";
StringBuilder data = new StringBuilder();
Iterator<String> it = sres.body().iterator();
while (it.hasNext()) {
String line = it.next();
if (line.isEmpty()) { // a blank line ends a frame
if (data.length() > 0) {
JsonNode p = null;
try { p = JSON.readTree(data.toString()); } catch (Exception ignored) { }
if (p != null) {
switch (event) {
case "delta": streamed.append(p.path("text").asText("")); break;
case "job": System.out.println("job " + p); break;
case "done": case "pending": done = p; break;
case "error": failure = p; break;
default: break;
}
}
}
event = "message";
data.setLength(0);
} else if (line.startsWith("event:")) event = line.substring(6).trim();
else if (line.startsWith("data:")) data.append(line.substring(5).trim());
}
}
if (failure != null) {
throw new RuntimeException(failure.path("code").asText() + ": " + failure.path("message").asText()
+ " (job " + failure.path("job_id").asText() + ")");
}
String full = done == null ? "" : done.path("output").path("output").asText("");
if (full.isEmpty()) full = streamed.toString();
System.out.println("truncated: " + (done != null && done.path("truncated").asBoolean(false))
+ ", charged: " + (done == null ? "-" : done.path("charged_credits").toString()));
JsonNode result = JSON.readTree(full.substring(full.indexOf('{'), full.lastIndexOf('}') + 1));
System.out.println(result.path("verdict").asText() + " - " + result.path("headline").asText());
for (JsonNode v : result.path("values")) {
System.out.println("value " + v.path("path").asText() + " " + v.path("type").asText() + " " + v.path("default").asText()
+ " " + (v.path("required").asBoolean(false) ? "required" : "optional"));
}
for (JsonNode o : result.path("open")) {
System.out.println("open " + o.path("path").asText() + " - " + o.path("reason").asText());
}
for (JsonNode q : result.path("questions")) {
System.out.println("question " + q.asText());
}
if (result.path("schema").isObject()) { // the page's "values.schema.json" export (Jackson spaces the colons differently)
Files.writeString(Path.of("values.schema.json"), JSON.writerWithDefaultPrettyPrinter().writeValueAsString(result.path("schema")) + "\n");
}
# the schema worked example: links-chart.yaml and links-facts.json from build-facts.js
# (for the schema of an audited chart, pass the fixed paste and the audit_handoff string as the last argument)
schema_input = make_input("schema", "links-chart.yaml", "links-facts.json",
"People keep setting slugLength as a string and pullPolicy with a typo. Make the schema catch that.")
schema_key = "chartplumb:schema:#{Digest::SHA256.hexdigest(JSON.generate(schema_input))[0, 16]}:a1"
uri, req = build_request("POST", "/run-stream", schema_input, { "Idempotency-Key" => schema_key })
done = failure = nil
streamed = +""
Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 300) do |http|
http.request(req) do |res|
unless res["content-type"].to_s.include?("text/event-stream")
env = (JSON.parse(res.body) rescue {})
unless res.is_a?(Net::HTTPSuccess)
raise ApiError.new(res.code.to_i, env.dig("error", "code"), env.dig("error", "message") || res.message)
end
done = env["data"]
next
end
buf = +""
res.read_body do |chunk|
buf << chunk
while (i = buf.index("\n\n"))
frame = buf.slice!(0, i + 2)
event = "message"
data = +""
frame.split("\n").each do |line|
if line.start_with?("event:") then event = line[6..].strip
elsif line.start_with?("data:") then data << line[5..].strip
end
end
next if data.empty?
payload = (JSON.parse(data) rescue nil)
next if payload.nil?
case event
when "delta" then streamed << payload["text"].to_s
when "job" then puts "job #{payload.inspect}"
when "done", "pending" then done = payload
when "error" then failure = payload
end
end
end
end
end
raise "#{failure["code"]}: #{failure["message"]} (job #{failure["job_id"]})" if failure
done ||= {}
full = (done["output"] || {})["output"].to_s
full = streamed if full.empty?
puts "truncated: #{done["truncated"] == true}, charged: #{done["charged_credits"]}"
result = JSON.parse(full[full.index("{")..full.rindex("}")])
puts "#{result["verdict"]} - #{result["headline"]}"
(result["values"] || []).each { |v| puts "value #{v["path"]} #{v["type"]} #{v["default"]} #{v["required"] == true ? "required" : "optional"}" }
(result["open"] || []).each { |o| puts "open #{o["path"]} - #{o["reason"]}" }
(result["questions"] || []).each { |q| puts "question #{q}" }
# what the page's "values.schema.json" export writes
File.write("values.schema.json", JSON.pretty_generate(result["schema"]) + "\n") if result["schema"].is_a?(Hash)
// the schema worked example: links-chart.yaml and links-facts.json from build-facts.js
// (for the schema of an audited chart, pass the fixed paste and the audit_handoff string as the last argument)
$schemaInput = make_input("schema", "links-chart.yaml", "links-facts.json",
"People keep setting slugLength as a string and pullPolicy with a typo. Make the schema catch that.");
$schemaKey = "chartplumb:schema:" . substr(hash("sha256", json_encode($schemaInput)), 0, 16) . ":a1";
$done = null; $failure = null; $streamed = ""; $buf = ""; $ctype = "";
$handle = function (string $event, string $data) use (&$done, &$failure, &$streamed) {
$p = json_decode($data);
if ($p === null) return; // not JSON: skipped, as sdk.js does
switch ($event) {
case "delta": $streamed .= $p->text ?? ""; break;
case "job": echo "job ", json_encode($p), "\n"; break;
case "done": case "pending": $done = $p; break;
case "error": $failure = $p; break;
}
};
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => headers_for(["Idempotency-Key" => $schemaKey]),
CURLOPT_POSTFIELDS => json_encode($schemaInput, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$ctype) {
if (stripos($line, "content-type:") === 0) $ctype = strtolower(trim(substr($line, 13)));
return strlen($line);
},
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$buf, &$ctype, $handle) {
$buf .= $chunk;
if (strpos($ctype, "text/event-stream") === false) return strlen($chunk); // plain JSON: parse at the end
while (($i = strpos($buf, "\n\n")) !== false) {
$frame = substr($buf, 0, $i);
$buf = substr($buf, $i + 2);
$event = "message"; $data = "";
foreach (explode("\n", $frame) as $line) {
if (strpos($line, "event:") === 0) $event = trim(substr($line, 6));
elseif (strpos($line, "data:") === 0) $data .= trim(substr($line, 5));
}
if ($data !== "") $handle($event, $data);
}
return strlen($chunk);
},
]);
if (curl_exec($ch) === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if (strpos($ctype, "text/event-stream") === false) {
$env = json_decode($buf) ?? new stdClass();
if ($status < 200 || $status > 299) {
throw new ApiError($status, $env->error->code ?? null, $env->error->message ?? "HTTP $status");
}
$done = $env->data ?? null;
}
if ($failure) throw new RuntimeException(($failure->code ?? "") . ": " . ($failure->message ?? "") . " (job " . ($failure->job_id ?? "") . ")");
$full = $done->output->output ?? "";
if ($full === "") $full = $streamed;
echo "truncated: ", json_encode(($done->truncated ?? false) === true), ", charged: ", $done->charged_credits ?? "-", "\n";
$result = json_decode(substr($full, strpos($full, "{"), strrpos($full, "}") - strpos($full, "{") + 1));
echo $result->verdict, " - ", $result->headline, "\n";
foreach ($result->values ?? [] as $v) echo "value ", $v->path, " ", $v->type ?? "", " ", $v->default ?? "", " ", ($v->required ?? false) === true ? "required" : "optional", "\n";
foreach ($result->open ?? [] as $o) echo "open ", $o->path, " - ", $o->reason ?? "", "\n";
foreach ($result->questions ?? [] as $q) echo "question ", $q, "\n";
// what the page's "values.schema.json" export writes (PHP's pretty print indents by 4)
if (is_object($result->schema ?? null)) {
file_put_contents("values.schema.json", json_encode($result->schema, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n");
}
// the schema worked example: links-chart.yaml and links-facts.json from build-facts.js
// (for the schema of an audited chart, pass the fixed paste and the audit_handoff string as the last argument)
var schemaInput = MakeInput("schema", "links-chart.yaml", "links-facts.json",
"People keep setting slugLength as a string and pullPolicy with a typo. Make the schema catch that.");
var schemaDigest = System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(schemaInput)));
var schemaKey = "chartplumb:schema:" + Convert.ToHexString(schemaDigest, 0, 8).ToLowerInvariant() + ":a1";
using var sreq = Api.Request("POST", "/run-stream", schemaInput, new Dictionary<string, string> { ["Idempotency-Key"] = schemaKey });
using var sres = await Api.Http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
JsonNode? done = null, failure = null;
var streamed = new StringBuilder();
if (sres.Content.Headers.ContentType?.MediaType != "text/event-stream")
{
JsonNode? env = null;
try { env = JsonNode.Parse(await sres.Content.ReadAsStringAsync()); } catch (JsonException) { }
if (!sres.IsSuccessStatusCode) throw Api.ErrorFrom(sres, env);
done = env?["data"];
}
else
{
using var reader = new StreamReader(await sres.Content.ReadAsStreamAsync());
var ev = "message";
var data = new StringBuilder();
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line.Length == 0) // a blank line ends a frame
{
if (data.Length > 0)
{
JsonNode? p = null;
try { p = JsonNode.Parse(data.ToString()); } catch (JsonException) { }
if (p != null)
{
switch (ev)
{
case "delta": streamed.Append(p["text"]?.GetValue<string>() ?? ""); break;
case "job": Console.WriteLine($"job {p.ToJsonString()}"); break;
case "done": case "pending": done = p; break;
case "error": failure = p; break;
}
}
}
ev = "message";
data.Clear();
}
else if (line.StartsWith("event:")) ev = line[6..].Trim();
else if (line.StartsWith("data:")) data.Append(line[5..].Trim());
}
}
if (failure != null) throw new Exception($"{failure["code"]}: {failure["message"]} (job {failure["job_id"]})");
var full = done?["output"]?["output"]?.GetValue<string>();
if (string.IsNullOrEmpty(full)) full = streamed.ToString();
var cut = done?["truncated"] is JsonValue tv && tv.TryGetValue(out bool cutShort) && cutShort;
Console.WriteLine($"truncated: {cut}, charged: {done?["charged_credits"]}");
var result = JsonNode.Parse(full[full.IndexOf('{')..(full.LastIndexOf('}') + 1)])!;
Console.WriteLine($"{result["verdict"]} - {result["headline"]}");
foreach (var v in result["values"]?.AsArray() ?? new JsonArray())
{
var req = v?["required"] is JsonValue rv && rv.TryGetValue(out bool rb) && rb ? "required" : "optional";
Console.WriteLine($"value {v?["path"]} {v?["type"]} {v?["default"]} {req}");
}
foreach (var o in result["open"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"open {o?["path"]} - {o?["reason"]}");
foreach (var q in result["questions"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"question {q}");
if (result["schema"] is JsonObject schema) // what the page's "values.schema.json" export writes
File.WriteAllText("values.schema.json", schema.ToJsonString(new JsonSerializerOptions { WriteIndented = true }) + "\n");
What the page does that you now do
- Reads the paste in the browser and builds
chartandfactsfrom it (helmkit.js), joining dropped files under# file:lines and cutting a large paste on whole files; you send the same two strings. - Prices every input with
/estimateand refuses to run belowmin_credits, or to run the schema lane on a paste with no values.yaml. - Sends each attempt with an input- and lane-derived
Idempotency-Key, and retries once withretry_notewhen a reply does not parse. - Closes a cut-off JSON reply and shows what arrived when
truncatedis true or the stream ends early. - Hands an audit to the schema lane as
audit_handoff, with every fix applied to the paste first, and only while that fixed paste is unchanged. - Checks the reply against the chart (
recon.js): files, lines, evidence, every fix put back in order and re-run through the free check, flag answers and verdict; the schema validated against the defaults, the values table against values.yaml, and the schema put back and re-checked; and repeated secrets.
Or skip all of this and use Chartplumb in the browser: the check, the flags, the starter values.schema.json, putting a fix or a schema back into the paste, and the Markdown and CSV exports are free.