Generating an architecture is cheap now. Knowing whether it will survive contact with a GPU is not. POST a model graph and get the verdict back as JSON: no browser, no LLM, deterministic, the same code path that answers the question inside the app.
| Endpoint | Answers | Use it when |
|---|---|---|
| POST /api/v1/check | The whole verdict: can it train, what would the run cost, where should it serve and how fast, and what is still a human's call. | An agent or a script needs the decision, not the rule list. |
| POST /api/v1/rank | Given k designs nobody has run yet: which of them will not run at all, and whether anything measured separates the rest. | An agent proposed more candidates than it can afford to train. |
| POST /api/v1/plan | A plan for one graph: layers, params, will it forward-pass, which GPUs it fits, a cost
estimate, and against a base what changed and how far it reaches, with
your policy lines applied. With a key, your own outcome history is attached too. |
A bot comments on a pull request; neurarch-trace --plan prints a card. |
| GET /api/v1/history | What this structure did the last time it trained under your key. | The bot wants the one line a model cannot write. |
| POST /api/v1/train | A real GPU run for a design, submitted on your account, and refused outright if the verifier says the design will not forward-pass. | An agent, or a bot on a pull request, wants the measurement and not another estimate. |
| GET /api/v1/train?job= | The curve, the final metrics, the wall clock and the cost of one of your runs. | Polling the run you just submitted, so the bot can post it. |
| POST /api/v1/fix | Unified diffs for a plan's blockers, built here from the code a model quotes back. Validated before they leave. Never applied here. | The bot opens a fix pull request and lets CI re-verify it. |
| POST /api/v1/lint | Just the structural rules that fired, with stable ids. | CI wants an exit code and a diff-able list. |
lint is one stage of check. Both are the same verifier the editor runs
on every edit, and neither calls a model.
If your graph cannot leave your network, the same four answers are available from a container you run yourself. See Run it inside your own network.
Mint a key in the app under Settings → Developer API. It's shown once; copy it then. Send it as a bearer token:
Authorization: Bearer nrk_your_key_here
Keys are hashed at rest (we store only a SHA-256); revoke any key from the same screen.
Use the www host. The apex answers every /api route
with a 307 to www.neurarch.com, and both curl -L and fetch
drop the Authorization header across that hop. A valid key sent to the apex comes
back as a 401 that blames you.
What a keyed call records. Every endpoint writes one anonymous structural row
for the graph you send: a fingerprint, the layer-type histogram, the edge count, and the
{ruleId, severity} pairs that fired. Never the graph, never parameter values or
layer names, and no caller identity, not even the account the key belongs to. It is the one
thing we record that you cannot switch off, because it is the grade we just computed for you;
if a graph cannot leave your network, run the verifier locally instead
(neurarch-mcp or the
GitHub Action compute the same
verdict offline). Full terms in the data policy.
Five stages run in order (pre-flight, data, train, evaluate, deploy) and the response carries what each of them concluded. The walk stops at the first blocker, because every verdict after a graph that cannot forward-pass would be fiction.
{
"model": {
"components": [
{ "id": "in", "type": "input", "name": "input",
"params": { "shape": [3, 32, 32] } },
{ "id": "c1", "type": "conv2d", "name": "conv1",
"params": { "inChannels": 3, "outChannels": 32, "kernelSize": 3, "padding": 1 } },
{ "id": "r1", "type": "relu", "name": "relu1", "params": {} },
{ "id": "p1", "type": "maxpool2d", "name": "pool1", "params": { "kernelSize": 2 } },
{ "id": "fl", "type": "flatten", "name": "flatten", "params": {} },
{ "id": "fc", "type": "linear", "name": "fc",
"params": { "inFeatures": 8192, "outFeatures": 10 } },
{ "id": "out", "type": "output", "name": "output", "params": {} }
],
"connections": [
{ "from": "in", "to": "c1" }, { "from": "c1", "to": "r1" },
{ "from": "r1", "to": "p1" }, { "from": "p1", "to": "fl" },
{ "from": "fl", "to": "fc" }, { "from": "fc", "to": "out" }
]
}
}
Input shapes carry no batch dimension: [3, 32, 32] is C, H, W. A
leading 1 is read as the channel axis and will quietly change every shape and
cost below it.
{
"verdict": "ask",
"outcome": "needs_input",
"summary": "No dataset is wired yet, so a run would train on random tensors.",
"stoppedAt": null,
"findings": [
{ "stage": "evaluate", "severity": "warn", "title": "No test cases",
"detail": "Nothing would catch a regression between one run and the next.",
"fix": "Starter cases can be derived from the input contract right now, for free." },
{ "stage": "deploy", "severity": "warn", "title": "Never trained",
"detail": "This analysis is about the architecture. Nothing has been trained, so there are no weights to ship." }
],
"stages": [
{ "stage": "preflight", "status": "ok",
"headline": "Ready to train: 5 layers, 0.08M params, about $0.04 / 3m on an A10G (24GB).",
"data": { "score": 98, "verdict": "ready", "blockers": [], "warnings": [],
"layers": 5, "params": 82826, "flops": 1001472,
"gpu": "A10G (24GB)", "estCostUsd": 0.0422, "estTrainSec": 138.3 } },
{ "stage": "data", "status": "needs_input", "headline": "No dataset is wired yet…",
"data": { "wired": false, "modality": "vision",
"contract": { "inputShape": [3, 32, 32], "inputDtype": "float32",
"outputDim": 10, "taskType": "classification" } } },
{ "stage": "train", "status": "needs_input", "headline": "Not trained yet…",
"data": { "lastRun": null, "estCostUsd": 0.0422, "gpu": "A10G (24GB)" } },
{ "stage": "evaluate", "status": "attention",
"headline": "Nothing measured yet: there is no training run to judge.", "data": { "run": null } },
{ "stage": "deploy", "status": "attention",
"headline": "Best target is Edge GPU (Jetson) (100/100): ~5.0ms per inference, 0.3 MB.",
"data": { "target": "edge-gpu", "score": 100, "latencyMs": 5.0,
"sizeMB": 0.316, "sizeMBQuantized": 0.079, "alternatives": [ /* nine scored targets */ ] } }
],
"decision": {
"question": "Which data should this train on?",
"because": "Everything else about the run is derived from the graph. This is not: the design says it wants vision input of shape (3, 32, 32), but not which corpus you have.",
"options": [
{ "label": "CIFAR-10", "value": "hf:cifar10", "hint": "60k 32×32 colour images, 10 classes" },
{ "label": "No data yet, smoke-test it", "value": "synthetic", "hint": "Random tensors matching the input contract" }
]
}
}
| Field | Meaning |
|---|---|
| verdict | block · warn · ask · ok. The worst status any stage reported. |
| outcome | blocked, needs_input, attention, or ok. |
| summary | One sentence, the same one the app leads with. When something is blocked it is priced: "1 blocker would have failed a $0.17 / 10m on an A10G run." |
| stoppedAt | The stage the walk stopped at, or null if all five ran. |
| findings[] | Only block and warn, each tagged with the stage that raised it. fix is present when there is one specific thing to change. |
| findings[].ruleId | The check that raised it, where the finding is a check: head-dim-divisibility, gqa-head-divisibility. Every id has a page at /r/<id>.html with the measurement behind it, so a blocker can be looked up rather than taken on our word. Absent on findings that are not checks, which is most of the later stages: "no test cases" and "never trained" are states of your workflow, not verdicts on the graph. |
| stages[].data | The machine-readable payload per stage: params, FLOPs, the GPU it fits, estimated cost and wall-clock, the derived input contract, the best deploy target with its latency and quantized size. Pre-flight also carries the memory a run would need (weightBytes, trainFootprintBytes) and samplesAssumed, which is true when the sample count behind the cost was a default rather than something you told us. A cost with no provenance is a claim, so the flag ships next to it. |
| decision | Present when something is genuinely a human's call, with because stating why it could not be derived. There are exactly two in the whole flow: which dataset, and whether to spend the money. Surface it, do not answer it: a tool that picks a corpus is inventing a premise. |
In the app, a question pauses the walk. Over HTTP it does not: a caller posting a bare graph has
no session, so "no dataset is wired" is true of every request and would otherwise hide the deploy
analysis behind it. A blocker still stops. Questions come back in
decision, and the stages after them still run.
curl -sS https://www.neurarch.com/api/v1/check \
-H "Authorization: Bearer $NEURARCH_API_KEY" \
-H "Content-Type: application/json" \
--data @model.json \
| jq '{verdict, summary, blockers: [.findings[] | select(.severity=="block") | .title]}'
Loop it: generate a graph, check it, feed findings[].fix back to the model, check
again. Nothing in that loop costs GPU time, which is the point of running it before one exists.
An agent can propose far more designs than it can afford to train, so the lever is which ones get the budget. POST up to 20 graphs and this returns an ordering, the ids that should not be given execution budget at all, and, in the same response, how much the ordering is actually worth.
It abstains, on purpose. Two published 2026 papers measure this task with
frozen frontier models reading candidate source code, and reach 61.5%
(arXiv:2601.05930) and 69.35%
(arXiv:2608.13940) pairwise accuracy. Those
models are forced to answer. A structural verifier is not: it separates designs that cannot run
from designs that can, which our 264-graph study measured at 96/96 and 80/80, and it has no
measured basis for separating two legal designs. So recommended comes back
null whenever the top rank is shared, and the calibration block says
what our ordering scored on the same axis. Reading a tie as a failure of the endpoint is
reading it correctly: that is what the measurement says today.
{
"candidates": [
{ "id": "wider", "model": { "components": [...], "connections": [...] } },
{ "id": "deeper", "model": { "components": [...], "connections": [...] } }
],
"tieBreak": "cost"
}
id is optional; candidates without one come back as candidate-0, candidate-1, and so on.
tieBreak is optional: "cost" or "params". Where the
measured ordering is silent, it breaks the tie on your stated rule, cheapest or smallest
first. It never reorders across measured ranks and never lifts a blocked design. The rule is
echoed back with how many positions it decided, and every reason it decided says so, because
"cheaper" is your budget's preference and not the verifier's.
{
"ranked": [
{
"id": "deeper", "rank": 1, "tier": "legal", "tiedWith": 2,
"blocking": 0, "warnings": 0, "outcomeFlags": [],
"params": 31120896, "estCostUsd": 0.197, "fitsGpu": "A10G (24GB)",
"otherStageBlockers": [],
"reasons": ["Runs, and trips no rule with a trained-outcome measurement behind it."]
},
{
"id": "unnormalized", "rank": 3, "tier": "legal", "tiedWith": 1,
"outcomeFlags": ["deep-no-norm"],
"reasons": ["Carries 1 finding measured against trained outcomes (deep-no-norm), …"]
},
{
"id": "broken", "rank": 4, "tier": "blocked", "blocking": 2,
"reasons": ["2 blocking findings: this graph does not forward-pass, so executing it spends budget on a crash."]
}
],
"recommended": null,
"recommendation": "2 of 3 legal candidates are tied at the top and nothing measured separates them…",
"budget": { "candidates": 3, "blocked": 1, "legal": 2, "wouldNotRun": ["broken"], "reclaimed": 0.333 },
"calibration": { "exclusion": {…}, "ordering": { "pairwiseAccuracy": 0.514, "coverage": 0.083, … } }
}
| Field | Meaning |
|---|---|
| budget.wouldNotRun | The reason to call this. These graphs crash on the forward pass; every GPU-second spent on them is spent on a stack trace. |
| recommended | The single candidate at the top rank, or null when the top rank is shared. Never a tie broken silently. |
| rank | 1-based, and tied candidates share a rank (so a shared first place reads 1, 1, 3, never 1, 2, 3). |
| measuredRank | The rank on measured evidence alone. Equal to rank unless a tieBreak moved the candidate inside a tie. |
| tieBreak | Your rule, echoed: { rule, decided, note }. decided is how many positions came from your rule rather than from a measurement. |
| outcomeFlags | Rules that fired and have a measured link to a trained outcome. The only thing used to order two runnable designs. |
| otherStageBlockers | Blockers past pre-flight, e.g. "Model too large" at deploy. Reported, never ranked on: a design can be undeployable and still be the one worth training. |
| params · estCostUsd · fitsGpu | Derived, returned per candidate, and deliberately not used in the measured ordering. Pass tieBreak to have a tie broken on one of them; we won't pretend cheaper means better, so the response says which positions were yours. |
| calibration | What the ordering scored on the pairwise axis the papers report, with its coverage, its sample size and the published comparators. Shipped in every response so the number is never read alone. |
curl -sS https://www.neurarch.com/api/v1/rank \ -H "Authorization: Bearer $NEURARCH_API_KEY" \ -H "Content-Type: application/json" \ --data @candidates.json \ | jq -r '.budget.wouldNotRun[]'
The front door. No key needed (60 plans an hour per address without one). A
policy block needs no key either, because your own rules can only make the
verdict stricter; with a key, one more thing happens, and it is the one that matters: your
own history for that structure is looked up and attached, so the bot gets the whole comment
from one call. share: true stores the graph at a public /p/<id>
URL and is the only way anything here is kept.
{
"model": { "components": [...], "connections": [...] },
"base": { "components": [...], "connections": [...] }, // optional: the version before
"policy": { // optional: your .neurarch.yml lines
"max_params": "200M", "must_fit": "A100-40GB",
"forbid_types": ["softmax"], "require_types": ["batchNorm"],
"max_layers": 120, "max_memory_gb": 30, "max_cost_usd": 5
},
"share": false,
"source": { "kind": "bot", "target": "models/gpt.py:GPT", "input": "1,128:long" }
}
{
"plan": {
"version": 1,
"summary": "GPT: 62 layers, 124.4M params, will run, fits a T4, about $1.2 to train, vs GPT: params +12.6M",
"model": { "name": "GPT", "layers": 62, "params": 124439808, "inputShape": [128], "outputShape": [128, 50257], "fingerprint": "3f1c9a02" },
"run": { "legal": true, "blockers": [], "warnings": [] },
"cost": { "estCostUsd": 1.21, "estTrainSec": 3820, "fitsGpu": "A10G (24GB)", "refusedRunUsd": null,
"gpuFits": { "T4": true, "A100_40": true, "H100_80": true }, "assumptions": "..." },
"diff": { "base": { "name": "GPT", "layers": 62, "params": 111820800 },
"added": 0, "removed": 0, "changed": 2, "paramsDelta": 12619008, "paramsDeltaPct": 11.3,
"memoryDeltaGb": 0.2, "costDeltaUsd": 0.12, "blastRadius": 48,
"lines": ["~ blocks.0.attn.embedDim 768 -> 832", "..."], "legalityChanged": null },
"policy": { "applied": true, "violations": 0 },
"history": { "rows": [ { "at": "2026-08-21T10:02:11Z", "metric": "valAcc", "value": 0.72,
"epochs": 3, "wallSec": 612, "estCostUsd": 0.40, "source": "colab" } ] }
},
"text": "Plan: GPT 62 layers · 124.4M params ...", // fixed width, for a terminal
"markdown": "**GPT: 62 layers, ...**\n\n| | |\n..." // for a pull request comment
}
refusedRunUsd is what the run this plan refuses was quoted at, and it is
null on any plan that did not refuse one, including a verdict we withheld. It is the same
figure as estCostUsd, restated where a caller acting on legal: false will
actually read it: the reason to ask for a plan before dispatch is the number attached to not
dispatching. It is a price, not a prevented run. We count a run as prevented only when someone asked
for it and we refused to start it, so summing this field across plans would count every graph anybody
ever pasted and is not a measurement of anything.
A policy line that fails is a blocker with stage: "policy" and the line quoted in
detail, so a red check says which rule of yours it broke. Budgets
(max_memory_gb, max_cost_usd) are warnings. When history is found,
markdown and text carry one extra line, and it is the only line in the
comment a model reading the diff could not have written:
Last time this structure trained here: valAcc 0.72, $0.40, 2026-08-21.
Key required, and the key's owner is the organisation: the answer contains your rows and
nobody else's, ever. The fingerprint is the eight hex characters a plan returns as
plan.model.fingerprint, and the match is exact, so these are outcomes of this
structure and not of one that merely resembles it. Newest first, at most 20.
plan does this lookup for you when a key is present, which is why the bot needs
one call and not two.
{ "fingerprint": "3f1c9a02",
"rows": [ { "at": "2026-08-21T10:02:11Z", "metric": "valAcc", "value": 0.72,
"epochs": 3, "wallSec": 612, "estCostUsd": 0.4, "source": "colab" } ] }
Two sources feed it, both owned by you: runs Neurarch orchestrated for your account
(simulated runs are excluded, because a heuristic curve is not an outcome), and outcomes
written under your key. Anonymous NEURARCH_REPORT rows have no owner and are
never returned here. An empty rows means this structure has no history under your
key, which is the answer for every structure until the first one trains.
Key required. This is the only endpoint here that spends money, and the order it does things in is the point: it builds the plan first and refuses to book a GPU for a design the verifier blocks. The run is charged to the account behind your key, under that plan's quota, concurrency cap, runtime ceiling and daily dollar ceiling, exactly like a run started in the app.
{
"model": { "components": [...], "connections": [...] },
"dataset": "cifar10", // mnist | cifar10 | tabular
"config": { "epochs": 3, "batchSize": 64, "lr": 0.001, "optimizer": "adamw" },
"gpu": "T4", // optional; T4 L4 A10G L40S A100-40GB A100-80GB H100 H200
"source": { "repo": "acme/models", "pr": 12 } // optional: where the run came from
}
{
"job_id": "job_1756900000000_a1b2c3",
"mode": "gpu",
"backend": "modal",
"config": { "epochs": 3, "batchSize": 64, "lr": 0.001, "optimizer": "adamw" }, // AFTER clamping
"plan": { "summary": "SmallCNN: 14 layers, 1.2M params, will run, fits a T4, about $0.30 to train", ... },
"text": "Plan: SmallCNN 14 layers · 1.2M params ..."
}
config is clamped, not validated: epochs to 1 to 20, batch size to 8 to 512,
learning rate to 1e-5 to 1, optimizer to adam, adamw or
sgd, and every other key you send is dropped rather than forwarded to the trainer.
It is echoed back for that reason, so a caller that asked for 200 epochs can see that it got
20.
| Status | Body | What happened |
|---|---|---|
| 422 | { "error": "blocked", "plan", "text" } | The verifier found a blocker, so nothing was submitted. The plan comes back with it, because an agent needs to know which layer and which number to change. This is the endpoint's reason to exist: generating designs is cheap, and training the ones that cannot forward-pass is where the money goes. |
| 503 | { "error": "gpu_not_configured" } | No managed GPU worker is available. It does not fall back to simulation. The app does, which is right for a person watching a canvas and wrong here: a simulated curve posted into a pull request as a training result is a fabricated measurement. |
| 404 | { "error": "No such job under this key." } | On the poll below, for a job your key does not own. Another account's job and a job that never existed are the same answer. |
403 and 429 come from the plan's GPU limits (big-GPU tier, jobs in
flight, jobs today, dollars today) and carry the number you hit.
Key required, and it returns the job only if your key's owner owns it. epochs is
the metric rows the worker pushed, in order, which is what a curve is drawn from;
metrics is the scalar summary.
{
"job_id": "job_1756900000000_a1b2c3",
"status": "completed", // queued | running | completed | error | cancelled
"mode": "gpu",
"dataset": "cifar10",
"epochs": [ { "epoch": 1, "trainLoss": 1.82, "valLoss": 1.51, "valAcc": 0.46 }, ... ],
"metrics": [ { "name": "valAcc", "value": 0.72 }, { "name": "valLoss", "value": 0.31 } ],
"wall_sec": 612,
"est_cost_usd": 0.4012,
"created_at": "2026-09-03T10:00:00Z",
"completed_at": "2026-09-03T10:10:12Z",
"fingerprint": "3f1c9a02",
"ledger": "written",
"model_trained": true,
"model_format": "onnx",
"model_url": "https://...supabase.co/storage/v1/object/sign/...?token=..." // 10 minutes
}
A finished run exports its weights to ONNX inside the training container, while they
are still the weights the last epoch left, and the worker uploads that file to your own
storage prefix. model_url is a signed link to it, valid for ten minutes, scoped
to your key's owner like the rest of this response. model_trained: true is a
claim about the weights, not about the file:
/api/train/convert-onnx also returns ONNX and it is random initialisation by
construction, because it exports a freshly built graph. This one is the model you paid to
train, and it will score what the run's valAcc says it scores.
When a finished run has no artifact you get model_trained: false and a
model_reason saying which kind of no it is (the run was simulated, it was a
Colab or Kaggle round trip whose weights are a checkpoint, or the export or its upload failed
in the worker). While a run is still going, neither field appears at all: there is no news
yet, and a false that turns true in forty seconds is worse than silence.
On the poll that first reads completed, one row is written to your own outcome
ledger under that fingerprint, so GET /api/v1/history and the history
line in a plan can quote it next time. ledger says what
happened: written, already (you polled twice; it is written once),
unavailable (this deployment cannot store it yet), or
skipped:simulated / skipped:no_metrics. A simulated run is never
written down, because a heuristic curve is not an outcome and would come back later as one.
Key required; each call counts as one agent message on your plan. Send the blockers and the files that define the model.
The model is never asked for a diff. It answers in search-and-replace blocks
over the text you sent, and the server computes the hunks, because counting context lines in an
@@ -a,b +c,d @@ header is a thing language models get wrong reliably. Every block
must name a path you sent and quote text that appears exactly once in it, whitespace
included; the diff is generated from the character range that matched, and re-validated before
it leaves. Paths come back bare, so each patch applies with patch -p0. The server
applies nothing, runs nothing, and keeps none of the files. The bot applies the diff, re-traces,
re-plans, and pushes only if the new plan is legal.
{
"model": { "components": [...], "connections": [...] },
"findings": [ { "title": "MultiheadAttention: embedDim 384 not divisible by numHeads 5", "detail": "head_dim would be 76.80", "stage": "preflight" } ],
"files": [ { "path": "models/tiny_gpt.py", "text": "import torch.nn as nn\n..." } ],
"policy": { "max_params": "200M" },
"maxPatches": 3
}
{
"patches": [ { "path": "models/tiny_gpt.py",
"diff": "--- models/tiny_gpt.py\n+++ models/tiny_gpt.py\n@@ -6,1 +6,1 @@\n- num_heads=5\n+ num_heads=6\n",
"findingTitles": ["MultiheadAttention: embedDim 384 not divisible by numHeads 5"] } ],
"rationale": "Six heads divide 384 into 64-wide heads; nothing else changes.",
"model": "deepseek/deepseek-chat"
}
422 means the model answered but its blocks did not pass validation, and the
message says which block and which of the failures it was: text that is in no file (a quote
that was invented), text that appears more than once (a quote too short to be unambiguous), a
path you never sent, an empty SEARCH section, more edits than maxPatches, or two
edits on the same lines. 500 is ours and never yours: the blocks were legal and
the diff we built from them failed our own validator. 429 is your agent quota;
503 is the provider.
{
"model": {
"components": [
{ "id": "in", "type": "input", "name": "input",
"params": { "shape": [1, 128] } },
{ "id": "attn", "type": "groupedQueryAttention", "name": "attn",
"params": { "embedDim": 512, "numHeads": 8, "numKVHeads": 3 } },
{ "id": "out", "type": "output", "name": "output", "params": {} }
],
"connections": [
{ "from": "in", "to": "attn" },
{ "from": "attn", "to": "out" }
]
}
}
The graph format is the same JSON the app exports (File → Export → JSON), so the easiest way to get a valid payload is to design once in the app and export.
{
"verdict": "fail",
"counts": { "block": 1, "warn": 0, "info": 0 },
"findings": [
{
"rule": "gqa-head-divisibility",
"severity": "block",
"message": "numHeads (8) must be divisible by numKVHeads (3)…",
"componentName": "attn",
"componentType": "groupedQueryAttention"
}
]
}
| Field | Meaning |
|---|---|
| verdict | fail if any blocker, else warn if any warning, else pass. Wire it to your CI exit code. |
| counts | Findings by severity. |
| findings[].rule | Stable rule id (e.g. gqa-head-divisibility, full-mha-serving-cost). Catalogue at /rules.html. |
| findings[].severity | block (won't run) · warn · info. |
| inferred | Present only when the graph you sent carries edges our PyTorch importer inferred rather than read out of forward() — two statements that happen to be adjacent, or an add the graph has no node for. Every check about layer adjacency held back on those edges, so a clean verdict on a graph with a non-zero inferred is a smaller claim than a clean verdict without one. Carries total, inferred, constructionOrder, unmodelledMerge. Absent for a graph authored by hand or drawn in the app, and absent when the importer read every edge. |
curl -sS https://www.neurarch.com/api/v1/lint \
-H "Authorization: Bearer $NEURARCH_API_KEY" \
-H "Content-Type: application/json" \
--data @model.json \
| tee result.json \
| grep -q '"verdict":"fail"' && { echo "Structural blockers found"; exit 1; } || true
A bank, a pharma company or an autonomous-driving team often cannot POST a model structure to an outside service at all. Not because they doubt us: the graph is the design, and the review process has no box for sending it to a startup's API. So the verifier also ships as one container you run yourself.
docker run --rm --network none -p 8080:8080 neurarch/verifier:local curl -sS http://localhost:8080/v1/check \ -H "Content-Type: application/json" \ --data @model.json
It answers POST /v1/plan, /v1/check, /v1/lint and
/v1/rank with request and response bodies field for field identical to the hosted
endpoints above, so a client written against one works against the other by changing the base
URL. No API key: there is no account behind it. A parity test runs all 36 shipped templates and
a seeded split of generated graphs through both the container and the hosted code path and
deep-compares every response, because a container that could disagree with us would make an
audit trail built on it worse than no audit trail.
What leaves your network: nothing. In its default configuration the container
makes no outbound connection of any kind. A graph you submit is parsed in memory, verified,
and returned in the response; it is never written to disk, never logged, never retained after
the response, and never transmitted. It runs as a non-root user, holds no credentials, opens
no database connection, and --network none is the documented way to run it rather
than a hardened mode. Optional aggregate telemetry exists, is off unless two environment
variables are both set, and can carry counts only: requests per action, verdict counts, how
many times each structural rule fired, and the container version.
GET /metrics/preview shows you exactly what a send would contain, in either mode.
| Endpoint | Answers |
|---|---|
| GET /version | The container version, the sha256 of every verifier bundle it loaded, and the rule-set id. Quote this pair alongside any verdict you archive: it is what makes the verdict reproducible later. |
| GET /healthz | Liveness, and which telemetry mode the process is in. |
What it cannot do, because it has no database: mint a shared /p/<id> plan
URL, attach your organisation's prior outcome history, record a corpus row, or create a citable
verification badge. Every response says so in a hosted_only array rather than
quietly returning less. Full setup, the security-review paragraph, how to verify an image and
how to pin a digest:
docs/SELF_HOSTED.md.
Every endpoint answers the same way.
| Status | When |
|---|---|
| 401 | Missing or invalid / revoked API key. |
| 400 | Body isn't { model: { components: [...] } }, or, for rank, isn't { candidates: [{ model }] }. |
| 413 | More than 2000 components, or more than 20 candidates on rank. |
| 422 | The graph couldn't be verified (malformed shapes). |