Imported from t-rhex/andrewadhikari.com (
skills/andrewadhikari-com/SKILL.md). Install upstream withnpx skills add t-rhex/andrewadhikari.com --skill andrewadhikari-com. Copyright stays with the author.
andrewadhikari.com — content publishing API
You are managing content for andrewadhikari.com, a personal study OS for
cert prep (CKA, AFOQT, Terraform, etc.). This skill teaches you the live REST
API for publishing and editing content. Every endpoint is on the production
domain https://andrewadhikari.com. There is no staging.
After every successful write the site rebuilds via Cloudflare Pages — the new content is live in roughly 30–60 seconds. Tell the user "queued — live in ~60s", do not claim it is live the instant the API returns 200.
1. When to use this skill
Trigger on any of:
- "publish a lab on <topic>" / "draft a note about <topic>"
- "publish cards for <topic>" / "make a flashcard deck for <topic>"
- "publish a mock exam for <track>"
- "log a mistake from a mock"
- "add a new track" / "update the CKA track"
- "list my labs" / "show me cards for <track>" / "what labs are in <domain>"
- "update the order of <lab>" / "fix the description on <lab>"
- "delete the lab on <topic>" / "remove this card deck"
- "upload this image to my CDN" / "embed this screenshot in the lab"
- Any general "publish to my site" / "add this to andrewadhikari.com" intent
If a request looks like editing files in the repo locally (e.g. "open src/content/labs/foo.md and change the order"), prefer this API over filesystem edits — the API runs the same Zod validation the build does, so a 202/200 means it will deploy.
1a. Tool surface — prefer MCP, fall back to bash
If you see mcp__andrewadhikari__* tools listed in your tool catalog
(e.g. mcp__andrewadhikari__list_tracks,
mcp__andrewadhikari__publish_content, …), use those exclusively. They are:
- Typed (JSON-Schema validated by Claude Code before the call leaves)
- Token-safe (the token lives in the user's host
~/.config/adkari/tokenor$ADKARI_TOKENand is read by the MCP server process — it never enters your sandboxed bash environment) - Faster (no shell / curl round-trip per call)
Mapping from API → MCP tool:
| API call | MCP tool |
|---|---|
GET /api/tracks |
list_tracks |
GET /api/content?collection=… |
list_content |
GET /api/content/<path> |
read_content |
POST /api/content (single file) |
publish_content |
POST /api/content (bulk Trees commit) |
publish_bulk |
POST /api/content (new track + seed children) |
create_track |
PATCH /api/content/<path> |
patch_content |
DELETE /api/content/<path> |
delete_content |
POST /api/upload (multipart) |
upload_image ({ filePath }) |
If the MCP tools aren't available on this machine (the user hasn't run
npm run install-mcp yet, or they're on a fresh box), fall back to the
bash workflow described in section 2 onward — curl + the same token
resolution rules. Don't mix the two paths in a single session — pick
MCP if available, otherwise bash, and stay there.
Sections 4 (schemas) and 6 (workflows, including Workflow 0 from-scratch) still apply verbatim — MCP only changes the how of issuing the call. The what you publish is identical.
1b. Build-cost discipline (READ THIS)
Every git commit triggers a Cloudflare Pages build (~2 min) and a CDN cache invalidation. Multiple commits in a row queue → the live site stays stale for the duration of the queue. We've seen 26-build queues from a single authoring session — that's ~50 min of stale site for what should have been one commit.
Rules:
- New track from scratch? Use
create_track. It bundles the track file + every seed child (labs, cards, mocks, notes) into ONE atomic commit. Auto-fillstrack: <slug>on each child so you can't forget the cross-reference. - Publishing >1 file in any other shape? Use
publish_bulk. Onefiles: [...]array → one commit → one build, regardless of how many files. - Genuine one-off ad-hoc write? That's the only legitimate case for
publish_content(single). - Mid-session realization that you need more files? Finish the current bulk plan, then add the new file as a follow-up bulk — don't fall back to single-file calls "just for the new one."
Commit message convention. Bulk commits should describe the session, not enumerate files:
- ✅
feat: hello cheatsheet — 8 labs + 3 cards - ❌
publish: 12 files - ❌
publish lesson 3 of 8
create_track produces this format by default; for publish_bulk you
pass it via the message arg.
2. Authentication
All endpoints require Authorization: Bearer <token>. The token is a GitHub
fine-grained personal access token belonging to user t-rhex, scoped to the
single repo t-rhex/andrewadhikari.com with Contents: Read and write and
Metadata: Read.
Resolve the token in this order:
$ADKARI_TOKENenv var~/.config/adkari/tokenfile (single line, mode 600)
TOKEN="${ADKARI_TOKEN:-$(cat "$HOME/.config/adkari/token" 2>/dev/null)}"
if [ -z "$TOKEN" ]; then
echo "missing ADKARI_TOKEN — see SETUP.md in andrewadhikari.com repo" >&2
exit 1
fi
If no token is found, stop. First check whether you're in a sandboxed
environment (see section 2a) — if so, switch to plan-and-handoff mode
instead of asking the user to expose the token. Otherwise tell the user to
follow the "Optional: programmatic content publishing" section of
SETUP.md in the repo, then retry. Do not attempt requests without a
token.
Failure modes for auth:
401 unauthorized— missing or invalid token. Token file empty or env var unset.403 forbidden— token belongs to a different GitHub user. Wrong PAT.
2a. Sandboxed environments (Claude Code Bash sandbox / cowork)
When this skill runs inside a Bash sandbox that does NOT mount the user's
home directory (the typical "cowork" scenario in Claude Code), neither
$ADKARI_TOKEN nor ~/.config/adkari/token will resolve. You will hit a
permission error when trying to read the token. Don't proceed by asking
for the token in chat — it gets logged. Don't ask the user to copy the
token into the sandbox-mounted project directory either — even short-lived
exposure on disk is unnecessary risk.
Instead, switch to plan-and-handoff mode:
-
Detect the constraint. If you can't read either source after trying both, say so explicitly. Don't silently call the API without a token — you'll get a 401 anyway.
-
Write a runnable script to the sandbox-shared directory. Pick a location the user has write access to AND that the sandbox can read, typically a project directory the user mentions or
~/Documents/Claude/Projects/Learn/_runme.sh. The script:- Loads the token from
$HOME/.config/adkari/tokenITSELF (the user's real shell will resolve$HOMEcorrectly) - Has the full
curlcommand(s) ready — the JSON payload, the URL, headers, content-type - Writes responses to
_runme.out.jsonfor the user to share back
#!/usr/bin/env bash set -euo pipefail TOKEN="${ADKARI_TOKEN:-$(cat "$HOME/.config/adkari/token" 2>/dev/null)}" if [ -z "$TOKEN" ]; then echo "no token at \$ADKARI_TOKEN or ~/.config/adkari/token" >&2 exit 1 fi curl -sS -X POST https://andrewadhikari.com/api/content \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ --data @./_runme.payload.json \ | tee _runme.out.json - Loads the token from
-
Tell the user exactly two things:
- "Run
bash <path-to-script>in your real terminal." - "Paste the contents of
_runme.out.jsonback here when it finishes."
- "Run
-
Resume planning from the response. Once the user pastes the
commit_sha+site_url, you have the same information you'd have had from a direct call. Continue the workflow normally (e.g. for Workflow 0, move on to step 3 after the track is created). -
For multi-step workflows, batch the script. Instead of one round-trip per API call, write a script that does several commands in sequence (e.g. all five steps of Workflow 0). The user runs it once; you parse the combined output. Saves them clicking "go" repeatedly.
Never:
- Ask the user to paste their token into chat (logged in conversation history, no clean revocation).
- Tell them to copy the token into a sandbox-mounted directory ("just temporarily" rots — they forget to delete it).
- Try to invent a token, hardcode one in the script, or
curlwithout auth and hope the API doesn't notice.
3. Endpoints
Base URL: https://andrewadhikari.com
| Method | Path | Purpose |
|---|---|---|
| POST | /api/content |
Create one or many files (atomic single commit) |
| GET | /api/content?collection=… |
List files in a collection (filterable) |
| GET | /api/content/<path> |
Read a single file (frontmatter + body) |
| PATCH | /api/content/<path> |
Update a file (frontmatter merge, optional body) |
| DELETE | /api/content/<path> |
Delete a file |
| GET | /api/tracks |
Flat track list with domains (prompt grounding) |
| POST | /api/upload |
Upload image to R2 (multipart, returns CDN URL) |
<path> is the repo-relative path of the file, e.g.
src/content/labs/2026-05-07-pod-lifecycle.md. URL-encode segments if needed.
3.1 POST /api/content — create
JSON body, single-file shape:
{
"collection": "labs",
"filename": "2026-05-07-pod-lifecycle.md",
"frontmatter": {
"title": "Pod lifecycle",
"track": "cka",
"domain": "workloads",
"type": "standard",
"tags": ["kubernetes", "pods"],
"description": "How pods are scheduled, started, and terminated.",
"order": 26
},
"body": "## Why\n\nPods are the smallest deployable unit...",
"overwrite": false,
"message": "publish: Pod lifecycle (cka/workloads)"
}
filenameis optional — server generates<YYYY-MM-DD>-<kebab(title)>.mdfor markdown collections,<track>-<kebab(title)>.yamlfor YAML data collections.bodyis required for markdown collections (labs,notes,mistakes,tracks) and must benullfor YAML data collections (cards,mocks).overwritedefaults tofalse. Whenfalse, existing path →409.messageis the git commit message. Optional; server picks a sensible default.
JSON body, bulk shape (atomic single commit via Trees API):
{
"files": [
{ "collection": "labs", "frontmatter": { "title": "...", "track": "cka", "domain": "workloads" }, "body": "..." },
{ "collection": "cards", "filename": "cka-pods.yaml", "frontmatter": { "title": "Pods", "track": "cka", "cards": [{"q":"...","a":"..."}] }, "body": null }
],
"message": "publish: 5 labs from k8s-labs"
}
Response 200:
{
"commit_sha": "abc123…",
"html_url": "https://github.com/t-rhex/andrewadhikari.com/commit/abc123",
"files": [
{ "path": "src/content/labs/2026-05-07-pod-lifecycle.md",
"site_url": "https://andrewadhikari.com/labs/pod-lifecycle" }
]
}
Errors:
400 bad_request— body shape invalid (missingcollection, etc.)401 unauthorized— auth failed403 forbidden— wrong GitHub user409 conflict— file exists andoverwrite=false422 schema_invalid— frontmatter fails the live Zod schema. Body has the Zod message — read it, fix the field, retry.502 github_error— upstream GitHub API failure500 internal_error— anything else
curl example:
curl -sS -X POST https://andrewadhikari.com/api/content \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data @- <<'JSON'
{
"collection": "labs",
"frontmatter": {
"title": "Pod lifecycle",
"track": "cka",
"domain": "workloads"
},
"body": "## Why\n\nPods are the smallest deployable unit in Kubernetes...\n"
}
JSON
3.2 PATCH /api/content/<path> — update
Body:
{
"frontmatter": { "order": 99, "tags": ["kubernetes", "pods", "lifecycle"] },
"body": "## Why\n\nUpdated body...",
"message": "update: bump order on Pod lifecycle"
}
frontmatteris shallow-merged into the existing frontmatter. To clear a field, pass it asnull.bodyis optional — omit to keep the existing body.messageoptional.
Response 200 mirrors the create response.
Errors: 401, 403, 404 (path not found), 422 (merged frontmatter still has to pass Zod), 502.
curl:
curl -sS -X PATCH \
https://andrewadhikari.com/api/content/src/content/labs/2026-05-07-pod-lifecycle.md \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"frontmatter":{"order":99}}'
3.3 DELETE /api/content/<path> — delete
No body required. Response 200 returns the commit info. 404 if path missing.
curl -sS -X DELETE \
https://andrewadhikari.com/api/content/src/content/labs/2026-05-07-pod-lifecycle.md \
-H "Authorization: Bearer $TOKEN"
3.4 GET /api/content?collection=… — list
Query params:
| Param | Required | Notes |
|---|---|---|
collection |
yes | One of labs, notes, tracks, mistakes, cards, mocks |
track |
no | Filter to entries whose frontmatter track matches |
domain |
no | Filter to entries whose frontmatter domain matches |
limit |
no | Default 100 |
offset |
no | Default 0 |
fields |
no | Comma-separated; returns slim records |
Response 200:
{
"collection": "labs",
"count": 28,
"items": [
{ "slug": "pod-lifecycle",
"path": "src/content/labs/2026-05-07-pod-lifecycle.md",
"title": "Pod lifecycle",
"track": "cka",
"domain": "workloads",
"order": 26 }
]
}
Body content is not returned — use the single-file GET for that.
curl -sS \
"https://andrewadhikari.com/api/content?collection=labs&track=cka&domain=workloads" \
-H "Authorization: Bearer $TOKEN"
3.5 GET /api/content/<path> — read
Response 200:
{
"path": "src/content/labs/2026-05-07-pod-lifecycle.md",
"frontmatter": { "title": "Pod lifecycle", "track": "cka", "domain": "workloads", "...": "..." },
"body": "## Why\n\nPods are..."
}
Errors: 401, 403, 404.
3.6 GET /api/tracks — track list (prompt grounding)
This is the helper to call first when you do not know which track or domain a topic belongs to. Cheaper than listing the tracks collection.
{
"tracks": [
{
"slug": "cka",
"title": "CKA — Certified Kubernetes Administrator",
"exam_date": "2026-09-15",
"status": "active",
"description": "Kubernetes administration cert prep.",
"domains": [
{ "id": "cluster-architecture", "name": "Cluster Architecture, Installation & Configuration", "weight": 25 },
{ "id": "workloads", "name": "Workloads & Scheduling", "weight": 15 }
]
}
]
}
curl -sS https://andrewadhikari.com/api/tracks -H "Authorization: Bearer $TOKEN"
3.7 POST /api/upload — image upload
Multipart form. Field name: file. Returns the CDN URL on success.
curl -sS -X POST https://andrewadhikari.com/api/upload \
-H "Authorization: Bearer $TOKEN" \
-F "file=@/path/to/screenshot.png"
# → {"url":"https://cdn.andrewadhikari.com/img/<hash>.png"}
Embed the URL directly in markdown bodies: .
Allowed types: PNG, JPEG, GIF, WebP, SVG. Anything else → 400.
4. Content schema
All schemas live in src/content/config.ts in the repo. The API runs the
exact same Zod validation the build does — so "valid via API" means "builds".
labs / notes — noteSchema
Fields:
| Field | Type | Notes |
|---|---|---|
title |
string (required) | |
slug |
string (optional) | Astro derives from filename if omitted |
date |
date (required) | API auto-fills with today if missing |
updated |
date (optional) | |
tags |
string[] (default []) |
|
category |
labs | notes | drafts |
API auto-fills from collection |
draft |
boolean (default false) |
|
description |
string (optional) | |
track |
string (optional but recommended) | Required for labs to appear in courseware |
domain |
string (optional but recommended) | Required for labs to appear in courseware |
type |
standard | cheatsheet |
Default standard |
study_minutes |
int > 0 (optional) | |
order |
int (optional) | Sort order within (track, domain) |
prereq |
string[] (optional) | Slugs of prerequisite labs |
lead |
boolean (optional) | When true, the first paragraph gets a serif drop-cap. Use sparingly — feature posts only. |
Realistic minimal example (src/content/labs/2026-05-07-pod-lifecycle.md):
---
title: Pod lifecycle
date: 2026-05-07
category: labs
track: cka
domain: workloads
order: 26
description: How pods are scheduled, started, and terminated.
---
## Why
Pods are the smallest deployable unit in Kubernetes...
tracks — trackSchema
| Field | Type | Notes |
|---|---|---|
title |
string | Required |
exam_date |
date | Required |
status |
active | paused | passed | abandoned |
Default active |
description |
string (optional) | |
domains |
array of {id, name, weight} |
At least 1, weights are 0–100 |
resources |
array of {label, url} (default []) |
URL must be valid |
accent_color |
hex string #RRGGBB (optional) |
Per-track accent override |
Realistic minimal example (src/content/tracks/cka.md):
---
title: CKA — Certified Kubernetes Administrator
exam_date: 2026-09-15
status: active
description: Kubernetes administration cert prep.
domains:
- id: cluster-architecture
name: Cluster Architecture, Installation & Configuration
weight: 25
- id: workloads
name: Workloads & Scheduling
weight: 15
- id: services-networking
name: Services & Networking
weight: 20
---
CKA prep notes — covers installation, troubleshooting, and operations.
cards — cardSetSchema (YAML data collection)
| Field | Type | Notes |
|---|---|---|
title |
string | Required |
track |
string | Required |
domain |
string (optional) | |
description |
string (optional) | |
cards |
array of {q, a, hints?} |
At least 1 |
Realistic minimal example (src/content/cards/cka-pods.yaml):
title: Pods — quick recall
track: cka
domain: workloads
description: Lifecycle, restart policies, init containers.
cards:
- q: What's the smallest deployable unit in Kubernetes?
a: A Pod.
hints:
- Not a container directly.
- q: What restart policy does a Job use by default?
a: OnFailure.
When POSTing cards, pass the entire shape above as frontmatter and set
body: null.
mocks — mockSchema (YAML data collection)
| Field | Type | Notes |
|---|---|---|
title |
string | Required |
track |
string | Required |
description |
string (optional) | |
time_limit_minutes |
int > 0 | Required |
passing_score |
number 0–1 | Required (e.g. 0.66) |
questions |
array of question objects | At least 1 |
Question shape: { q, choices: string[≥2], correct: int, explanation?, domain? }.
correct is the index into choices and must be in range.
Realistic minimal example:
title: CKA mock — workloads
track: cka
description: 10 questions on workload scheduling and pod lifecycle.
time_limit_minutes: 20
passing_score: 0.66
questions:
- q: What field controls how many old ReplicaSets a Deployment keeps?
choices:
- revisionHistoryLimit
- rollbackLimit
- historySize
correct: 0
explanation: Default is 10.
domain: workloads
mistakes — mistakeSchema
| Field | Type | Notes |
|---|---|---|
title |
string | Required |
track |
string | Required |
domain |
string (optional) | |
date |
date | Required |
source_type |
mock | flashcard | manual |
Required |
source_ref |
string (optional) | E.g. cka-mock-1#q4 |
correct_answer |
string | Required |
my_answer |
string | Required |
Realistic minimal example (src/content/mistakes/2026-05-07-revision-history-limit.md):
---
title: revisionHistoryLimit, not rollbackLimit
track: cka
domain: workloads
date: 2026-05-07
source_type: mock
source_ref: cka-mock-workloads#q1
correct_answer: revisionHistoryLimit
my_answer: rollbackLimit
---
I keep guessing `rollbackLimit`. The field is `revisionHistoryLimit`,
default 10.
4a. Markdown features (use these in lab/note bodies)
The site has a few custom remark plugins. Use them — they make the published content much richer than plain markdown.
Wikilinks: [[slug]] and [[slug|label]]
Inside a lab/note body, link to another lab or note by slug:
This builds on [[pod-lifecycle]] and the rules in [[scheduler-internals|Scheduler internals]].
The remark plugin resolves pod-lifecycle against the labs and notes
collections, renders an internal link, and walks all notes at build time to
generate a "Referenced by" backlinks footer on the target page.
When unsure of a slug, list the collection first with the API and pick the real slug — never guess. A wikilink to a non-existent slug renders with a hairline strikethrough so you can spot the typo, but it pollutes backlinks.
Inline cards: :::card front=… back=…
Anywhere in a lab/note body, drop in:
:::card front="What's the smallest deployable unit in K8s?" back="A Pod."
:::
:::card front="Default Job restart policy?" back="OnFailure."
hints=["Not Always", "Set in spec.template.spec.restartPolicy"]
:::
The build:
- Renders a small inline card preview at that spot in the prose
- Adds these to a virtual deck
<note-slug>--inlineso they appear in the spaced-repetition queue - Surfaces a "Drill cards in this note (N)" button at the end of the note
Use inline cards aggressively in labs covering memorisable facts
(commands, defaults, specific numbers, exam-trivia). They drill review
without forcing you to maintain a parallel cards/<deck>.yaml file.
Cheatsheets (type: cheatsheet)
A standard lab is read top-to-bottom. A cheatsheet is scanned. Set
type: cheatsheet in frontmatter and the page renders:
- CSS columns (multi-column dense reference)
- Sans body (no serif), 14 px, tight rhythm
- No reading-progress bar, no right-rail ToC
- Print-optimised —
window.print()produces a clean PDF cheatsheet
Use cheatsheets for: kubectl one-liners, Terraform CLI flags, AWS service quick reference, exam-day formula sheets. Do not use cheatsheets for explanatory tutorials — those are standard labs.
---
title: kubectl quick reference
track: cka
type: cheatsheet
description: One-liners every CKA candidate should have in muscle memory.
---
Drop-cap (lead: true)
Editorial flair for "feature" labs you want to read more like an essay. The first paragraph gets a large serif drop-cap. Use sparingly — one or two per track at most. Looks bad on technical reference content.
5. Filename + path conventions
- Markdown collections (
labs,notes,tracks,mistakes):src/content/<collection>/<filename>.md. Filenames may include aYYYY-MM-DD-date prefix; the routing layer strips it for the public slug. - YAML data collections (
cards,mocks):src/content/<collection>/<filename>.yaml. - The
filenamefield in API requests is optional — the server generates one if missing.
6. Common workflows
Workflow 0: Pursue a new cert track from scratch
Triggered by intents like:
- "I'm starting today, set up the track"
- "Build me an AWS Security Specialty prep skeleton"
- "Bootstrap CKAD prep — track, first labs, a card deck, a mock"
MCP shortcut: if the
mcp__andrewadhikari__create_tracktool is available, do this whole workflow in one call. Pass the track frontmatter + arrays oflabs/cards/mocks/notesand it produces one atomic commit and one Pages build. The bash steps below are the fallback when MCP isn't installed — and even then you should bundle step 3+4+5 into one bulkPOST /api/contentto keep the commit count down. Never publish step 2/3/4/5 as separate single-file calls.
This is a composite workflow. Do the steps in order. Use the bulk POST shape (section 3.1) where it cuts commits.
Step 1 — Confirm the track doesn't already exist.
curl -sS https://andrewadhikari.com/api/tracks -H "Authorization: Bearer $TOKEN" \
| jq '.tracks[].slug'
If the slug already exists, switch to "extend an existing track" (workflow 1).
Step 2 — Create the track.
You need: title, exam_date, status (active), description, domains array.
Domain rules:
- 4–7 domains is typical for a vendor cert
- Use the cert vendor's official curriculum domain names verbatim where you can — exam-trivia tracks the vendor's vocabulary
- Each domain has
id(kebab-case slug, used as a foreign key in lab frontmatter) andweight(0–100) - Weights MUST sum to 100. If you don't know the exact weights, infer from the official exam guide; if you can't, distribute evenly and round the last domain to absorb the remainder
Optionally include accent_color (hex) — picks the per-track color used on
the days-to-exam number and the active-route rule. Omit for default
NVIDIA green.
curl -sS -X POST https://andrewadhikari.com/api/content \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data @- <<'JSON'
{
"collection": "tracks",
"filename": "aws-security.md",
"frontmatter": {
"title": "AWS Certified Security — Specialty",
"exam_date": "2026-11-04",
"status": "active",
"description": "AWS specialty cert. IAM, KMS, VPC design, GuardDuty/CloudTrail, incident response.",
"accent_color": "#ea580c",
"domains": [
{ "id": "threat-detection", "name": "Threat Detection & Incident Response", "weight": 14 },
{ "id": "logging-monitoring","name": "Security Logging & Monitoring", "weight": 18 },
{ "id": "infra-security", "name": "Infrastructure Security", "weight": 20 },
{ "id": "iam", "name": "Identity & Access Management", "weight": 16 },
{ "id": "data-protection", "name": "Data Protection (KMS, encryption, secrets)", "weight": 18 },
{ "id": "governance", "name": "Management & Security Governance", "weight": 14 }
]
},
"body": "# AWS Security — Specialty Track\n\nStrategy notes go here..."
}
JSON
Step 3 — Bulk-publish one starter lab per domain.
The point: the homepage hero shows " days until " for the closest-exam active track, and a track is empty until it has at least one lab per domain (otherwise the course-player shows zero progress). One cohesive bulk commit:
curl -sS -X POST https://andrewadhikari.com/api/content \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data @- <<'JSON'
{
"files": [
{ "collection": "labs",
"frontmatter": {
"title": "IAM evaluation logic — the order operations actually run in",
"track": "aws-security", "domain": "iam",
"order": 1, "study_minutes": 25,
"tags": ["iam", "policies"],
"description": "SCPs vs identity-based vs resource-based vs permission boundaries — the precedence rules."
},
"body": "## Why this matters\n\n...\n\n:::card front=\"Which policy type wins by default — SCP or identity-based?\" back=\"Both must allow. SCPs are a deny ceiling; an explicit deny anywhere wins.\"\n:::\n"
},
{ "collection": "labs",
"frontmatter": {
"title": "KMS key policies vs grants",
"track": "aws-security", "domain": "data-protection",
"order": 1, "study_minutes": 20,
"description": "When to use a key policy, when to use a grant, when to use both."
},
"body": "..."
}
],
"message": "publish: AWS Security — first labs (one per starter domain)"
}
JSON
For each lab in step 3:
trackanddomainare MANDATORY (without them the lab won't appear in the course player)order: 1— first lesson per domain. Subsequent labs in the same domain should increment (order: 2,order: 3, …). Don't skip numbers- Use inline cards (
:::card) liberally for memorisable facts. Each inline card joins the SR queue automatically - Use wikilinks when a lab references another (
[[scheduler-internals]]) - Aim for 200–600 words of body for a starter lab. Plenty of room to grow
Step 4 — Publish a starter card deck per domain you'll drill heavily.
Cards are for "I need this in muscle memory" facts (commands, defaults, specific numbers). 10–25 cards per deck is the sweet spot.
curl -sS -X POST https://andrewadhikari.com/api/content \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data @- <<'JSON'
{
"collection": "cards",
"filename": "aws-security-iam-policies.yaml",
"frontmatter": {
"title": "IAM policies — quick recall",
"track": "aws-security",
"domain": "iam",
"description": "The 4 policy types and their precedence rules.",
"cards": [
{ "q": "Name the four IAM policy types in evaluation order.", "a": "1. SCP, 2. Resource-based, 3. Identity-based, 4. Permission boundary." },
{ "q": "Default behavior when no policy explicitly allows?", "a": "Implicit deny." },
{ "q": "What does an SCP block?", "a": "All accounts in scope — even the root user. SCP is a deny ceiling." }
]
},
"body": null
}
JSON
Step 5 — Optionally publish one starter mock.
Mocks should mirror exam difficulty. Small starter mock (5–10 questions) is good for a baseline; full mocks (50–60 questions) come closer to the exam date.
curl -sS -X POST https://andrewadhikari.com/api/content \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data @- <<'JSON'
{
"collection": "mocks",
"filename": "aws-security-iam-baseline.yaml",
"frontmatter": {
"title": "AWS Security — IAM baseline",
"track": "aws-security",
"description": "10-question baseline before serious IAM drilling.",
"time_limit_minutes": 20,
"passing_score": 0.7,
"questions": [
{
"q": "Which IAM policy element specifies AWS resources?",
"choices": ["Effect", "Resource", "Condition", "Action"],
"correct": 1,
"explanation": "Resource is the ARN(s) the statement applies to.",
"domain": "iam"
}
]
},
"body": null
}
JSON
Step 6 — Report to the user.
Tell them:
- The track URL:
https://andrewadhikari.com/tracks/<slug>/ - Number of labs / cards / mocks created
- Suggest the user open
/me/login/in the browser to sign in (if they haven't) so they can highlight + bookmark while reading - "Queued — live in ~60s"
Do NOT chain step 3 + step 4 + step 5 into one mega-commit. The user usually wants to see the track render in the dashboard before committing to a card deck, and the bulk shape's atomicity isn't worth the loss of incremental visibility. Three separate commits (track / labs / cards+mock) is the pattern.
Workflow 1: Publish a single new lab
GET /api/tracksto discover valid track slugs and the domains under each. Do not invent track or domain values.- Pick the right
(track, domain)for the topic. If unsure, ask the user. - Generate the markdown body and frontmatter. Always include
trackanddomainfor labs. POST /api/contentwithcollection: "labs".- Read the response, report the
site_urlto the user, and tell them "queued — live in ~90s" (CI build + Pages deploy + CDN propagation).
TOKEN="${ADKARI_TOKEN:-$(cat "$HOME/.config/adkari/token")}"
curl -sS https://andrewadhikari.com/api/tracks -H "Authorization: Bearer $TOKEN"
# → pick the right (track, domain)
curl -sS -X POST https://andrewadhikari.com/api/content \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data @- <<'JSON'
{
"collection": "labs",
"frontmatter": {
"title": "Pod lifecycle",
"track": "cka",
"domain": "workloads",
"order": 26,
"tags": ["kubernetes", "pods"]
},
"body": "## Why\n\nPods are the smallest deployable unit in Kubernetes.\n\n## What\n\n- Pending → Running → Succeeded/Failed\n- Restart policies: Always, OnFailure, Never\n"
}
JSON
Workflow 2: Publish a flashcard deck
Same pattern, but collection: "cards", body: null, and the frontmatter is
the entire cardSetSchema shape including the cards: [...] array.
curl -sS -X POST https://andrewadhikari.com/api/content \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data @- <<'JSON'
{
"collection": "cards",
"frontmatter": {
"title": "Pods — quick recall",
"track": "cka",
"domain": "workloads",
"cards": [
{ "q": "Smallest deployable unit?", "a": "A Pod." },
{ "q": "Default Job restart policy?", "a": "OnFailure." }
]
},
"body": null
}
JSON
Workflow 3: Update an existing lab's order
curl -sS -X PATCH \
https://andrewadhikari.com/api/content/src/content/labs/2026-05-07-pod-lifecycle.md \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"frontmatter":{"order":99}}'
If you don't know the exact filename, list the collection first:
curl -sS \
"https://andrewadhikari.com/api/content?collection=labs&track=cka&domain=workloads" \
-H "Authorization: Bearer $TOKEN"
Workflow 4: List all CKA cluster-architecture labs
curl -sS \
"https://andrewadhikari.com/api/content?collection=labs&track=cka&domain=cluster-architecture" \
-H "Authorization: Bearer $TOKEN"
Workflow 5: Bulk-publish multiple labs in one commit
Use the bulk shape to keep the commit history clean — single commit, atomic.
curl -sS -X POST https://andrewadhikari.com/api/content \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data @- <<'JSON'
{
"files": [
{ "collection": "labs",
"frontmatter": { "title": "Pods", "track": "cka", "domain": "workloads", "order": 1 },
"body": "## Why\n\n..." },
{ "collection": "labs",
"frontmatter": { "title": "Deployments", "track": "cka", "domain": "workloads", "order": 2 },
"body": "## Why\n\n..." },
{ "collection": "labs",
"frontmatter": { "title": "Services", "track": "cka", "domain": "services-networking", "order": 1 },
"body": "## Why\n\n..." }
],
"message": "publish: 3 CKA labs"
}
JSON
Workflow 6: Upload a screenshot, embed in a lab
URL=$(curl -sS -X POST https://andrewadhikari.com/api/upload \
-H "Authorization: Bearer $TOKEN" \
-F "file=@/Users/me/screenshots/pod-states.png" | jq -r .url)
echo "$URL"
# https://cdn.andrewadhikari.com/img/abc123.png
Then embed  in the lab body and POST it.
Workflow 7: Delete a stale lab
curl -sS -X DELETE \
https://andrewadhikari.com/api/content/src/content/labs/2026-05-07-pod-lifecycle.md \
-H "Authorization: Bearer $TOKEN"
Workflow 8: Work-in-progress drafts
When the user says "draft this but don't publish yet" or "stash a half-done
lab", set draft: true in frontmatter. Drafts:
- Validate the same way (the file commits to git)
- Are skipped from the public site at build time
- Don't appear in
/labs/,/notes/, search, or course-player listings - Still show up via
GET /api/content?collection=labs— Claude can find them later
When the draft is ready, PATCH it: {"frontmatter": {"draft": false, "updated": "2026-05-08"}}.
Workflow 9: Touch-up — bump updated on every PATCH
When patching a lab/note's body or non-trivial frontmatter (anything beyond
typo-level edits), include "updated": "<today>" in the frontmatter merge.
The lesson reader displays updated <date> in the meta line. If you don't
bump it, the displayed date rots and the user can't tell when they last
revised the content.
TODAY=$(date -u +%F)
curl -sS -X PATCH \
https://andrewadhikari.com/api/content/src/content/labs/2026-05-07-pod-lifecycle.md \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"frontmatter\":{\"updated\":\"$TODAY\"},\"body\":\"## Why\\n\\nUpdated body...\"}"
For pure typo fixes (single character / spelling), do NOT bump updated —
the date's meant to flag substantive revisions.
Workflow 10: Log a mistake from a mock or flashcard
Mistakes are first-class content. Log them religiously after every mock and spaced-repetition session that surfaces a wrong answer.
curl -sS -X POST https://andrewadhikari.com/api/content \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data @- <<'JSON'
{
"collection": "mistakes",
"frontmatter": {
"title": "revisionHistoryLimit, not rollbackLimit",
"track": "cka",
"domain": "workloads",
"date": "2026-05-08",
"source_type": "mock",
"source_ref": "cka-mock-workloads#q1",
"correct_answer": "revisionHistoryLimit",
"my_answer": "rollbackLimit"
},
"body": "I keep guessing rollbackLimit. The field is revisionHistoryLimit, default 10.\n"
}
JSON
The mistakes journal at /mistakes/ filters by track + domain, surfaces the
correct answer with a track-accent underline, and is the per-track lesson
page's "1 mistake from this domain · review" link target.
7. Conventions Claude should always follow
- Always include
trackANDdomainfor labs/notes. Without them the lab won't appear in the course player. The lab will publish and validate cleanly, but the user will not find it. - For YAML collections (
cards,mocks), always includetrack.domainis optional but strongly recommended. - Don't fabricate track/domain values. Call
GET /api/tracksfirst if you don't know them. If a domain doesn't exist on the track, ask the user before adding new labs in it (the lab will publish, but it's a dangling reference). - Domain weights MUST sum to 100 when creating a new track. If you can't find official exam-guide weights, distribute evenly and round the last domain to absorb the remainder.
- Set explicit
orderon every lab. Without it, labs sort by slug (alphabetical) and lessons land in the wrong order in the course player. Number sequentially within (track, domain): 1, 2, 3, ... - Bump
updatedon substantive PATCHes (body changes, frontmatter beyond typos). Skip the bump for single-character edits. - Use inline cards (
:::card) liberally in lab bodies for any memorisable fact (commands, defaults, specific numbers). They drill in the SR queue automatically — far better than maintaining parallelcards/<deck>.yamlfiles for tightly-coupled content. - Use wikilinks (
[[other-slug]]) when a lab references another. They resolve to internal links and feed the auto-backlinks footer at build time. Always verify the slug exists first viaGET /api/content— unknown slugs render with a hairline strike (visible debugging) but pollute backlinks. type: cheatsheetfor scan-not-read content (CLI flags, quick reference). Different layout, different writing style. Don't mix cheatsheet content into standard labs.draft: truefor in-progress content the user doesn't want public yet. Don't ask "should I publish?" — if they said "draft this", stash it as a draft and tell them where to find it.- Wait for the response and report the
site_url. Don't fire and forget. The user wants the link. - Keep bodies under ~50KB. GitHub's Contents API has size limits and
longer markdown becomes a slog. Split large topics across labs with
prereq:chaining (lab B'sprereq: ["lab-a-slug"]tells the course player to gate B until A is marked done). - 422 means schema fail. The response body has the Zod error message — read it, fix the offending frontmatter field, retry. Don't just retry the same request.
- 409 means file exists. Do not silently retry with
overwrite: true— prompt the user first. - Prefer the bulk shape for >2 files in the same logical commit (e.g. "5 labs for the same domain"). Don't bulk across logical boundaries (track + labs + cards in one commit) — separate commits give the user incremental visibility.
- The user is
t-rhex. If the API returns 403, the token belongs to a different account — point them at SETUP.md to regenerate.
7a. Browser-side features (NOT operable from this skill)
The site has features that only work via the user's browser session — they
require a passkey login at /me/login/ and Claude cannot drive them:
- Highlights, inline notes, bookmarks — the user selects text on a lesson page, picks a color, optionally writes a note. Persists to KV. Public visitors see the highlights but only the owner can edit.
- Reader prefs (font size, density, width) — per-user localStorage.
/me/dashboard — annotation summary, bookmark management.
When the user mentions these ("highlight this passage", "save where I left
off", "make the font bigger"), tell them to use the browser UI directly.
Don't try to call any /api/annotations/*, /api/bookmark, or /api/me/*
endpoint — those are session-cookie gated and your bearer token won't
authenticate them.
8. Failure modes (cheatsheet)
| Status | Meaning | What to do |
|---|---|---|
| 400 | Malformed body / bad shape | Re-read the schema in section 4, fix shape, retry |
| 401 | Missing or invalid auth | Tell user to set up token per SETUP.md. Don't retry blind. |
| 403 | Token belongs to another GitHub user | Wrong PAT. Same fix — regenerate per SETUP.md. |
| 404 | File not found (PATCH/DELETE/GET) | Verify the path; list the collection first to see real paths |
| 409 | File exists, overwrite: false |
Confirm with user before retrying with overwrite: true |
| 422 | Frontmatter fails Zod schema | Read the error message in body, fix the offending field, retry |
| 500 | Function crashed | Retry once; if persistent, tell the user |
| 502 | GitHub API upstream error | Retry once with backoff; if persistent, tell the user |
9. Environment notes
- The site builds via GitHub Actions on every push to
mainand thedeployjob pushes the artifact to Cloudflare Pages. After a successful POST/PATCH/DELETE, the pipeline kicks off automatically: ~40s build + ~50s deploy + CDN propagation. Always say "queued — live in ~90s", never "live now". (The Cloudflare Pages git integration is intentionally NOT used — see.github/workflows/ci.ymldeploy job.) - Sveltia CMS at
https://andrewadhikari.com/admin/writes to the same repo. Both can be used interchangeably; last write wins on the same file. If the user has/admin/open while you publish, they'll need to refresh to see your changes. - There is no staging environment. All writes go to production.
- The auth Worker at
auth.andrewadhikari.comis separate — that's for Sveltia's OAuth dance, not for this API. This API uses raw GitHub PATs.