← Base File Fixer / API
Tokens

Drive Base File Fixer from your own code

Base File Fixer checks an Obsidian Bases .base file before you open it. In the page, and free, a strict YAML reader gives line-accurate errors, the Bases file format is checked (filters, formulas, properties, summaries, views), every filter and formula is parsed and given a type pass (Duration fields before rounding, guarded date() calls, defined and non-circular formulas, exact summary names), and the file is run over sample notes you paste to preview each view.

The metered lanes read what the page found: fix answers every finding and returns the corrected file; build writes a new .base file from a description of what it should show. The page runs any file in a reply back through the same checks and preview; from the API, paste the returned file into the page to get the same re-check.

Two lanes: the task field

tasksendyou get back
fixbase, facts; symptom optionalheadline, verdict on the original file (broken, fix_first, ok), a stance on every finding (finding_responses), changes (where, before, after, why), fixed_yaml (the complete corrected file, or "" when nothing needs to change) and to_check.
buildrequest, facts; base optional (a file to extend)headline, base_yaml (the complete file), explanation (one entry per top-level part), properties_used, assumptions and to_check.

If task is missing or unrecognised, the model picks the closer lane and says which in the reply's task; always send it, and check the reply's task against it.

Worked example: fix

A small reading list with three findings from the page: formulas.days_reading calls .round() on a Duration (an error), groupBy.direction is lowercase (an error), and date(started) is not guarded against notes without started (a warning). No sample notes were pasted, so facts.notes and facts.preview are null. This is the exact body the page sends:

{
 "task": "fix",
 "base": "filters:\n  and:\n    - file.hasTag(\"book\")\nformulas:\n  days_reading: (today() - date(started)).round(0)\nviews:\n  - type: table\n    name: Reading\n    order:\n      - file.name\n      - author\n      - formula.days_reading\n    groupBy:\n      property: status\n      direction: asc\n",
 "facts": "{\"checker\":\"Base File Fixer (YAML, Bases schema, expression parse and type checks, preview over pasted notes)\",\"status\":\"broken\",\"preview_clock\":\"2026-09-27 12:00\",\"base_path\":\"Reading.base\",\"findings\":[{\"id\":\"F1\",\"severity\":\"error\",\"source\":\"expr\",\"code\":\"duration-method\",\"where\":\"formulas.days_reading\",\"line\":5,\"text\":\"Subtracting dates gives a Duration, which has no .round(). Read a number first: (...).days.round(...) - the skill's own rule.\",\"quote\":\"(today() - date(started)).round(0)\"},{\"id\":\"F2\",\"severity\":\"error\",\"source\":\"schema\",\"code\":\"group-direction\",\"where\":\"views[0].groupBy.direction\",\"line\":15,\"text\":\"direction must be ASC or DESC (uppercase), not 'asc'.\",\"quote\":\"\"},{\"id\":\"F3\",\"severity\":\"warning\",\"source\":\"expr\",\"code\":\"unguarded-date\",\"where\":\"formulas.days_reading\",\"line\":5,\"text\":\"date(started) errors on any note without a started value (the skill's 'missing null check'). Guard it: if(started, ..., \\\"\\\").\",\"quote\":\"(today() - date(started)).round(0)\"}],\"findings_total\":3,\"formula_types\":{\"days_reading\":\"number\"},\"notes\":null,\"preview\":null,\"base_sent\":{\"lines\":16,\"lines_cut\":0}}",
 "symptom": "The days_reading column shows an error on every row."
}

A reply in the lane's shape (illustrative; the page re-checked this fixed_yaml and it has no findings):

{
 "task": "fix",
 "headline": "formulas.days_reading called .round() on a Duration and read started without a guard, and the grouping direction was lowercase; the fixed file reads .days behind if(started, ...) and groups by status ASC.",
 "verdict": "broken",
 "finding_responses": [
  {
   "ref": "F1",
   "stance": "fixed",
   "note": "today() minus a date is a Duration, which has no .round(). The formula now reads .days, which is already a whole number."
  },
  {
   "ref": "F2",
   "stance": "fixed",
   "note": "views[0].groupBy.direction is now ASC; only uppercase ASC or DESC is valid."
  },
  {
   "ref": "F3",
   "stance": "fixed",
   "note": "The formula now returns \"\" on a note without started instead of an error."
  }
 ],
 "changes": [
  {
   "where": "formulas.days_reading",
   "before": "days_reading: (today() - date(started)).round(0)",
   "after": "days_reading: 'if(started, (today() - date(started)).days, \"\")'",
   "why": "Reads a Duration field instead of rounding the Duration, and guards notes without started (single quotes because the formula holds double quotes)."
  },
  {
   "where": "views[0].groupBy.direction",
   "before": "direction: asc",
   "after": "direction: ASC",
   "why": "groupBy.direction takes ASC or DESC in uppercase."
  }
 ],
 "fixed_yaml": "filters:\n  and:\n    - file.hasTag(\"book\")\nformulas:\n  days_reading: 'if(started, (today() - date(started)).days, \"\")'\nviews:\n  - type: table\n    name: Reading\n    order:\n      - file.name\n      - author\n      - formula.days_reading\n    groupBy:\n      property: status\n      direction: ASC\n",
 "to_check": [
  "That your book notes spell the property started exactly so - no sample notes were given, so the page could not compare names.",
  "That status is the property you want the Reading table grouped by."
 ]
}

verdict describes the original file: broken when the YAML does not parse or any real finding is an error, fix_first when only warnings are real, ok when nothing needs to change. Each before is copied exactly from base ("" for an added line).

Worked example: build

A recipe base described in one sentence, with three sample notes pasted into the page. facts.notes summarises those notes (folders, tags, and each property with its note count, types and up to three examples), so the model can use the vault's exact property names:

{
 "task": "build",
 "request": "All my recipes (tagged recipe) in a table sorted by rating, highest first, with cuisine, minutes and days since I last cooked each one; and a cards view of the quick ones under 30 minutes.",
 "facts": "{\"preview_clock\":\"2026-09-27 12:00\",\"base_path\":\"Recipes.base\",\"notes\":{\"files\":3,\"markdown\":3,\"extensions\":{\"md\":3},\"folders\":[\"Recipes\"],\"tags\":[\"quick\",\"recipe\"],\"properties\":[{\"name\":\"cuisine\",\"notes\":3,\"types\":[\"string\"],\"examples\":[\"Middle Eastern\",\"Indian\",\"Thai\"]},{\"name\":\"last_cooked\",\"notes\":2,\"types\":[\"date\"],\"examples\":[\"2026-09-12\",\"2026-08-30\"]},{\"name\":\"minutes\",\"notes\":3,\"types\":[\"number\"],\"examples\":[\"30\",\"45\",\"20\"]},{\"name\":\"rating\",\"notes\":3,\"types\":[\"number\"],\"examples\":[\"5\",\"4\"]},{\"name\":\"tags\",\"notes\":3,\"types\":[\"list\"],\"examples\":[\"recipe\",\"recipe, quick\"]}],\"properties_total\":5}}"
}

A reply in the lane's shape (illustrative; the page re-checked this base_yaml over the same notes: no findings, 3 rows in the table and 1 in Quick):

{
 "task": "build",
 "headline": "Your recipe notes in a table by rating, highest first, with days since each was last cooked, plus a cards view of recipes under 30 minutes.",
 "base_yaml": "filters:\n  and:\n    - file.hasTag(\"recipe\")\nformulas:\n  days_since_cooked: 'if(last_cooked, (today() - date(last_cooked)).days, \"\")'\nproperties:\n  formula.days_since_cooked:\n    displayName: Days since cooked\nviews:\n  - type: table\n    name: All recipes\n    order:\n      - file.name\n      - cuisine\n      - minutes\n      - rating\n      - formula.days_since_cooked\n    sort:\n      - property: rating\n        direction: DESC\n  - type: cards\n    name: Quick\n    filters:\n      and:\n        - \"minutes < 30\"\n    order:\n      - file.name\n      - cuisine\n      - minutes\n",
 "explanation": [
  {
   "part": "filters",
   "text": "Keeps notes tagged recipe; file.hasTag also matches nested tags such as recipe/dessert."
  },
  {
   "part": "formulas",
   "text": "days_since_cooked reads .days from today() minus last_cooked, and is empty on a recipe with no last_cooked (Dal tadka)."
  },
  {
   "part": "properties",
   "text": "Labels the formula column Days since cooked."
  },
  {
   "part": "views",
   "text": "All recipes is a table sorted by rating DESC; Quick is a cards view that adds the filter minutes < 30."
  }
 ],
 "properties_used": [
  "cuisine",
  "minutes",
  "rating",
  "last_cooked"
 ],
 "assumptions": [
  "Under 30 minutes is read as minutes < 30, so a 30-minute recipe (Shakshuka) is not in Quick; the quick tag is not used."
 ],
 "to_check": [
  "Whether Quick should show notes tagged quick instead - swap the view filter for file.hasTag(\"quick\")."
 ]
}

properties_used lists note properties only (never file.* or formula.*). Every property the file reads that is not in facts.notes.properties should appear in assumptions; with facts.notes null, every property name is a guess and is listed there.

Input fields

Every field is a string; facts is JSON text, not an object.

fieldtyperequiredmeaning
taskstringyes"fix" or "build".
factsstringyesWhat the page's checker established, as JSON text (below). "{}" is accepted.
basestringfix; optional for buildThe .base file (YAML). Fix: the file to repair. Build: an existing file to extend instead of starting over. The page sends at most 30,000 characters, keeping whole lines from the head and the tail around a YAML comment that says how many middle lines were cut, and records the cut in facts.base_sent.
symptomstringnoFix lane: what you saw in Obsidian (an error in a column, rows missing). At most 2,000 characters in the page. Addressed in headline or a change.
requeststringbuildWhat the base should show: which notes (tag, folder or property), which columns, how sorted or grouped, any totals or top-N. The page asks for at least 15 characters and sends at most 4,000.
retry_notestringnoOnly on a reformat retry, when a reply was not one JSON object.

The facts string

Fix lane (from the page's fixFacts):

{"checker": "Base File Fixer (YAML, Bases schema, expression parse and type checks, preview over pasted notes)",
 "status": "broken|fix_first|ok",
 "preview_clock": "2026-09-27 12:00",
 "base_path": "Reading.base",
 "findings": [{"id": "F1", "severity": "error|warning|info", "source": "yaml|schema|expr|data",
               "code": "duration-method", "where": "formulas.days_reading", "line": 5,
               "text": "...", "quote": "..."}],
 "findings_total": 3,
 "formula_types": {"days_reading": "number"},
 "notes": {...} | null,
 "preview": [...] | null,
 "base_sent": {"lines": 16, "lines_cut": 0}}

findings holds at most the first 60 (findings_total is the full count); ids run F1, F2, ... in order, and the reply answers each exactly once, in id order. where names the key (views[0].groupBy.direction), line is the file line or null, quote the expression involved. A yaml, schema or expr error is treated as true unless the file proves otherwise; data findings come from running the file over your sample notes. formula_types is the type pass's result per formula. preview_clock is the now()/today() the preview used.

notes (both lanes, null without sample notes): {"files", "markdown", "extensions", "folders", "tags", "properties": [{"name", "notes", "types", "examples"}], "properties_total"} — at most 40 folders, 60 tags and 80 properties. preview (fix lane, null without notes) has one entry per view: {"view", "type", "matched", "shown", "of", "skipped", "columns", "filter_errors", "cell_errors", "first_rows"}.

Build lane (from the page's buildFacts):

{"preview_clock": "2026-09-27 12:00",
 "base_path": "Recipes.base",
 "notes": {...} | null,
 "current_base": {"status": "broken|fix_first|ok", "errors": 0, "warnings": 1}}

current_base is present only when a file was in the page's editor.

You can build facts yourself. If you have not run the browser checker, send "{}" or a minimal string such as {"findings":[],"notes":null}: the model then works from the file (or the request) alone, has no findings to answer, and treats every property name it uses in a build as a guess. The page's checks are what make a fix accountable, so for the full picture paste the file (and a few notes) into the page; after a run, Download .json saves the exact facts it sent as facts_sent.

The output

One JSON object, serialised as a string at data.output.output. Parse it and check that task is the lane you sent.

fix:
{"task": "fix", "headline": "", "verdict": "broken|fix_first|ok",
 "finding_responses": [{"ref": "F1", "stance": "fixed|not_a_problem|needs_you", "note": ""}],
 "changes": [{"where": "formulas.age", "before": "", "after": "", "why": ""}],
 "fixed_yaml": "the complete corrected .base file, or \"\"",
 "to_check": [""]}

build:
{"task": "build", "headline": "", "base_yaml": "the complete .base file",
 "explanation": [{"part": "filters|formulas|properties|summaries|views", "text": ""}],
 "properties_used": [""], "assumptions": [""], "to_check": [""]}

stance: fixed (fixed_yaml resolves it), not_a_problem (the finding does not apply, with the reason), needs_you (it depends on your vault: a property name only you know, a plugin, your intent). An info finding is fixed if the file changed for it, otherwise not_a_problem. Every YAML file in a reply is complete, not a diff.

Base URL and the envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses the same envelope, so one helper covers the whole API:

{"ok": true, "data": {"job_id": "job_...", "status": "queued"}}
{"ok": false, "error": {"code": "payment_required", "message": "..."}}

The token is minted for this app (the guest endpoint takes {"slug":"basefile-fixer"} in its body), so there is no slug header afterwards. Send it as Authorization: Bearer ....

The input object IS the request body. There is no {"input": ...} wrapper: a wrapped body reaches the model as an object with an input key and no task, so it cannot route the request. Note that /estimate does not validate the body at all - a clean estimate does not prove the input is right.

Error codes

statuscodewhat to do
400validation_errorThe request was rejected as malformed. Send a JSON object; keep every field a string (facts is a JSON-encoded string, not an object), which is the shape the app itself sends.
401unauthorizedThe token is missing, malformed or expired. Get a new one from the token page.
402payment_requiredThe balance is below min_credits. Call /estimate first and top up.
403forbiddenThe token is valid but not for this app, or a guest token tried a metered run the app does not sponsor. Sign in for a personal token.
404not_foundUnknown job id, or the app slug does not exist.
409conflictThe same Idempotency-Key was replayed with a different body. Change the key or send the original input.
429rate_limitedToo many requests. Back off and retry; do not tight-loop.
5xxinternalA server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice.

1. A tiny client

One helper that sends the token, unwraps data and raises on ok: false. The token comes from the token page (Copy token or Copy shell export); step 2 covers the kinds of token and minting one from code. In every language the steps below continue the same script: Go and Java put steps 2 to 7 inside main, in order.

# Every call is the same three things: the base URL, your bearer token,
# and a JSON body. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="basefile-fixer"
TOKEN="${SKILLSAFE_TOKEN:-YOUR_TOKEN}"   # from https://basefile-fixer.skillsafe.ai/tokens.html

call() {                  # call <path> [json-body]
  if [ -n "$2" ]; then
    curl -sS -X POST "$BASE/$1" \
      -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d "$2"
  else
    curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN"
  fi
}

2. Get a token

The easiest route is the token page: it shows the token this browser already holds, with Copy token and Copy shell export buttons, and a sign-in button for a personal token. A guest token, minted with POST /guest and {"slug":"basefile-fixer"}, can call /me and /estimate; both lanes are metered, so /run and /run-stream need a personal token unless /estimate reports sponsor_enabled: true.

# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
#   https://basefile-fixer.skillsafe.ai/tokens.html
#   export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead. A guest token is enough
# for /me and /estimate; a run needs a personal token from signing in.
curl -sS -X POST "$BASE/guest" \
  -H "Content-Type: application/json" -d '{"slug":"basefile-fixer"}'
# {"ok":true,"data":{"token":"...","subject_type":"guest"}}

3. Check the session and the balance

call me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}

4. Price the run (free)

/estimate returns the model binding and the credits a run would reserve. It creates no job and charges nothing. Expect model_alias gpt-terra. hold_credits is a reservation, not the price. min_credits is the least balance that can start a run. What you pay is charged_credits, reported on the finished job, usually far lower. The body is the input object itself, with no {"input": ...} wrapper. /estimate does no input validation, so check the shape yourself: an object whose every value is a string, task equal to fix or build, a facts that is a JSON string parsing to an object, and a non-empty base (fix) or request (build). Save one of the worked examples above as body.json to try it.

# body.json is the input object itself - no {"input": ...} wrapper. estimate does
# not validate it, so check the shape first:
python3 -c '
import json
b = json.load(open("body.json"))
assert isinstance(b, dict) and b.get("task") in ("fix", "build"), "task must be fix or build"
assert all(isinstance(v, str) for v in b.values()), "every field is a string"
assert isinstance(json.loads(b["facts"]), dict), "facts is a JSON string of an object"
need = "base" if b["task"] == "fix" else "request"
assert b.get(need, "").strip(), b["task"] + " needs " + need
'
INPUT=$(cat body.json)

call estimate "$INPUT"
# {"ok":true,"data":{"model":"...","model_alias":"gpt-terra",
#   "markup_bps":...,"hold_credits":...,"min_credits":...,"sponsor_enabled":false}}
# hold_credits is RESERVED, not the price; charged_credits after the run is the cost.

5. Run it, then poll

POST /run returns a job_id; poll GET /jobs/{id} until it is terminal. The reply is a string at data.output.output: parse it (step 7). Send an Idempotency-Key built from the lane, a hash of the input and the attempt number, basefile-fixer:<lane>:<hash>:a<attempt>, so a retried request returns the same job instead of billing a second run. Use one key per distinct input: an edited base, facts, symptom or request is a new hash, and replaying an old key with a different body is a 409. Any stable digest of the body works. Leave retry_note out of the hash and bump the attempt instead.

# Always send an Idempotency-Key derived from the input. A retried request with
# the same key returns the SAME job instead of billing a second run.
LANE=$(printf '%s' "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["task"])')   # fix or build
KEY="basefile-fixer:$LANE:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"

JOB=$(curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')

while :; do
  OUT=$(call "jobs/$JOB")
  STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
  [ "$STATUS" = "succeeded" ] && break
  [ "$STATUS" = "failed" ] && echo "$OUT" && exit 1
  sleep 2
done

# {"ok":true,"data":{"job_id":"job_...","status":"succeeded",
#   "output":{"output":"{\"task\":\"fix\",\"headline\":\"...\",\"verdict\":\"broken\", ...}"},
#   "charged_credits":...,"truncated":false}}
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])' > reply.json

6. Or stream it

POST /run-stream takes the same body and headers as /run (use it instead of step 5, not after it with the same key; build the key as step 5 does) and answers with server-sent events: job (the job id), delta (chunks of the reply) and done (the status, charged_credits, truncated and, when present, the full output). A browser page may receive only tick heartbeats and then done, never a delta, so take the reply from done.output.output when it is there, fall back to the concatenated deltas, and fall back again to GET /jobs/{id}.

# Server-sent events. `delta` events carry chunks of the reply; `done` carries the
# status, charged_credits and the truncated flag. Ignore `tick` heartbeats.
curl -N -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -H "Accept: text/event-stream" \
  -d "$INPUT"

# event: job    {"job_id":"job_..."}
# event: delta  {"text":"{\"task\":\"fix\",\"headline\":\"formulas.days_reading"}
# event: done   {"status":"succeeded","charged_credits":...,"truncated":false}

7. Parse the reply, and re-check the file

The reply is one JSON object serialised as a string (text from step 5, or streamed from step 6). Parse it and check that task is the lane you asked for. Then check the file it returns (fixed_yaml for a fix, base_yaml for a build) before you use it: paste it into the page, with your sample notes, which runs the same YAML, schema and expression checks and previews every view. An empty fixed_yaml means nothing needed to change.

# The reply is a JSON string inside data.output.output (saved as reply.json in step 5):
python3 -c '
import json
r = json.load(open("reply.json"))
print(r["task"], r["headline"])
if r["task"] == "fix":
    print("verdict:", r["verdict"])
    for f in r["finding_responses"]: print(f["ref"], f["stance"], f["note"])
    for c in r["changes"]: print("-", c["where"], ":", c["before"], "->", c["after"])
    if r["fixed_yaml"]: open("fixed.base", "w").write(r["fixed_yaml"])
else:
    for e in r["explanation"]: print(e["part"], "-", e["text"])
    print("assumptions:", r["assumptions"])
    open("new.base", "w").write(r["base_yaml"])
for x in r["to_check"]: print("check:", x)
'
# Paste fixed.base or new.base into https://basefile-fixer.skillsafe.ai/ to re-check and preview it.

Costs

Invariants worth asserting