Public API
The stable, documented contract the Positronick CLI (Positronick/cli), the MCP server, and any agent consume. Everything here is intentionally boring: shapes only change additively, and anything marked reserved exists so clients can rely on it later without a breaking change.
Read endpoints are unauthenticated over HTTPS; the auth endpoints (below) cover the CLI. Errors share one shape:
{ "error": { "code": "not_found", "message": "soul \"x\" not found" } }
Codes in use: not_found · invalid_type · invalid_input · unknown_profile ·
unauthorized · forbidden · conflict · rate_limited. Dates serialize as ISO-8601 strings.
Souls
GET /api/souls
200 { "souls": SoulCard[] } — every published soul, without the content body. Each card
also carries two additive convenience fields (origin-relative, computed per request) so an
agent can enumerate and install in one pass:
mdUrl— absolute URL of the rawGET /api/souls/[slug].mdinstall endpoint.installCmd— the ready-to-runcurl <mdUrl> > ~/.hermes/SOUL.mdline.
These are append-only; existing SoulCard fields are unchanged. ?q= is reserved for
server-side search (issue #11): currently accepted and ignored (the full set is returned;
clients filter locally).
GET /api/souls/[slug]
200 { "soul": Soul } — one published soul including content (the raw SOUL.md body).
This is the read for positronick soul show; it does not increment the download counter.
301→/api/souls/<current-slug>when the slug is a published soul's historical slug.404with the error shape otherwise.
GET /api/souls/[slug].md
The install contract (load-bearing, never change its shape): returns the raw SOUL.md
body verbatim (text/markdown, content-disposition: attachment) and increments
downloadCount. 301 on historical slugs.
curl https://positronick.com/api/souls/<slug>.md > ~/.hermes/SOUL.md
Registry listings
Listings catalog official external tooling. type is one of
harness | cli | mcp | agent | skill | plugin | loop.
GET /api/listings
200 { "listings": Listing[] } — every published listing, joined with its authoring
profile (profileHandle, profileName denormalized).
?type=<type>filters to one type. Case-insensitive. Unknown type →400 invalid_typewith the valid set in the message. Missing/empty → all types.?q=is reserved (see souls) — accepted and ignored today.
GET /api/listings/[slug]
200 { "listing": Listing } | 404. Includes the jsonb data extras — for loop
listings that's LoopData (goal, checkCommand, exitCondition, maxIterations,
compatibleTools, kickoff), which positronick loop show renders.
The blog reads (GET /api/blog, /api/blog/[slug], /api/blog/[slug].md) and the
research/"what's new" endpoint (GET /api/research) are documented under Blog.
Blog
Posts are editorial articles plus mirrored GitHub releases / RSS items. kind is one of
article | release | link. Only published posts are ever returned; a release/link post
carries a canonicalUrl backlink to the original (Positronick stays the canonical host).
GET /api/blog
200 { "posts": PostCard[] } — every published post, newest-first, without the content body,
joined with its author profile (authorHandle, authorName, authorTier) and any related tool
(listingSlug, listingName).
?kind=<kind>filters to one kind. Case-insensitive. Unknown →400 invalid_inputnaming the valid set. Missing/empty → all kinds.?q=is reserved (see souls) — accepted and ignored today.
GET /api/blog/[slug]
200 { "post": Post } — one published post including content (the raw markdown body).
301 → current slug on a historical slug; 404 otherwise. Does not bump viewCount.
GET /api/blog/[slug].md
Raw markdown (frontmatter + body), text/markdown, short public cache. The syndication contract
for agents/newsletters — positronick blog show and citing a post by its contentHash. 301 on
historical slugs.
GET /api/research
The "what's new" endpoint so agents avoid stale knowledge. Read-only over published posts.
200 { "results": ResearchItem[], "latest": string|null }.
- All params optional:
q,kind,category,tag,since(ISO-8601; returns only posts withpublishedAt > since),limit(1–100, default 20). Bad values →400 invalid_input. latestis the newestpublishedAtwithin the same filter (kind/category/tag/q) — use it as the nextsinceto poll only the delta. AResearchItemis compact:slug, title, excerpt, kind, category, tags, url, mdUrl, canonicalUrl, contentHash, publishedAt.
GET /blog/feed.xml
RSS 2.0 of the latest published posts (1-hour cache) — a reader feed and a machine surface. guid
is the permalink; pubDate uses the stable publishedAt.
Installer
GET /install.sh
POSIX sh installer for the CLI (text/x-shellscript, 5-minute cache). Downloads the
platform binary from the GitHub Releases of CLI_RELEASES_REPO (default Positronick/cli),
verifies it against the release checksums.txt, installs to
${POSITRONICK_INSTALL_DIR:-$HOME/.local/bin} with a pck alias symlink — no root.
Version-pinnable: POSITRONICK_VERSION=0.1.0 curl -fsSL https://positronick.com/install.sh | sh.
Asset-name contract with the CLI's release pipeline: positronick_<os>_<arch>.tar.gz
(linux|darwin × amd64|arm64) plus checksums.txt, attached to a v* release.
Type reference
SoulCard/Soul, Listing, LoopData, PostCard/Post, and ResearchItem are defined in
src/lib/types.ts — the single source of truth the CLI's Go types
mirror field-for-field (camelCase JSON).
Auth (CLI)
How the Positronick CLI authenticates. Three carriers resolve through ONE server seam
(auth.api.getSession in hooks.server.ts): browser cookies, Authorization: Bearer <session-token>, and x-api-key. Every fact below was verified live against the running
app (2026-06-10), not copied from docs.
Device flow (RFC 8628) — positronick login
All endpoints live under /api/auth and accept application/json bodies only —
form-encoded requests get 415 (Better Auth deviates from the RFC's form encoding here;
CLI implementers take note).
POST /api/auth/device/codebody{"client_id":"positronick-cli"}→{device_code, user_code, verification_uri, verification_uri_complete, expires_in: 1800, interval: 5}. Unknown client ids →400 {"error":"invalid_client"}(allowlist:DEVICE_FLOW_CLIENT_IDS, defaultpositronick-cli).- The human opens
https://positronick.com/device, signs in (any social provider), enters the code. The viewing session binds the code (only that session may approve — the plugin's anti-phishing posture); the page shows the requesting client and expiry, then Approve / Deny. Page lookups are rate-limited per IP (10/min) on top of Better Auth's own/device/*rules. - CLI polls
POST /api/auth/device/tokenbody{"grant_type":"urn:ietf:params:oauth:grant-type:device_code","device_code":"…","client_id":"positronick-cli"}:{"error":"authorization_pending"}→ keep polling everyintervals{"error":"slow_down"}→ add 5s to the interval (enforced server-side — verified){"error":"access_denied"}/{"error":"expired_token"}→ stop- success →
{"access_token","token_type":"Bearer","expires_in":604799,"scope":""}
Measured: the access_token IS a Better Auth session token — a session row with a
7-day lifetime (sliding via Better Auth's updateAge). There is no refresh token:
on 401 the CLI re-runs login, or exchanges the session for a long-lived API key right
after login (POST /api/auth/api-key/create).
API keys — unattended agents / CI
- Created on the account page ("API keys" panel) or by an authenticated CLI via
POST /api/auth/api-key/create{"name":"…","expiresIn":<seconds>}(client units: seconds; server default 90 days, max 365 —defaultExpiresInis seconds too; the plugin's type docs claiming ms are wrong, verified in source and live). - Raw key (
posi_…, 64+ chars) is returned once; hashed at rest thereafter. Theposi_prefix has a gitleaks rule (.gitleaks.toml). - Sent as
x-api-key: posi_…. A valid key mocks a session for its owner (enableSessionForAPIKeys), so every existing guard works unchanged — including the admin union (verified: role flip flows through a mock session). - Per-key rate limit: 120 requests / 60s (plugin default of 10/day is overridden).
- Revocation: account page, or
POST /api/auth/api-key/delete{"keyId"}.
Whoami — GET /api/me
One call answers "who am I and am I an admin?" for any carrier:
200 {"user":{"id","name","email","image","githubLogin"},"isAdmin":bool}401 {"error":{"code":"unauthorized","message":"authentication required — run \positronick login`"}}`
isAdmin is the server-side union — user.role === 'admin' OR membership in
ADMIN_GITHUB_IDS (see src/lib/server/guards.ts) — which get-session alone cannot
expose. The CLI caches this bit to decide whether to reveal hidden admin commands; the
server enforces it regardless on every admin endpoint.
Credential handling contract (CLI side)
- Token cache:
${POSITRONICK_CONFIG_DIR:-${XDG_CONFIG_HOME:-~/.config}/positronick}/credentials.json, mode0600. POSITRONICK_API_KEYenv always wins over the cached token and is never persisted.- Credentials are keyed to the base URL (a prod token is never sent to localhost).
Admin write API
Authenticated content authoring for the CLI's hidden admin commands (positronick soul create/update, listing create/update, loop create). Auth via any carrier (cookie,
Authorization: Bearer, x-api-key); the ladder is 401 unauthorized with no session,
403 forbidden for non-admins (guards.isAdmin union). Validation reuses the seed's
own validators (src/lib/server/seed/soulFields.ts / listingFields.ts), so 422
messages are the seed's verbatim and anything the API accepts would pass pnpm seed.
Souls
POST /api/admin/souls— create. Body: the soul fields withoutid(server-assigned ULID; a clientid→ 422). Required:slug, name, authorHandle, tagline, category, frameworks[], version, license, content. Optional:authorName, authorUrl, description, tags, models, repoUrl, slugHistory, status(draft|pending|published, defaultpublished). →201 {"soul":{…,"source":"api"}}·409 conflicton a taken slug.GET /api/admin/souls/[id]— any status, includessource.PATCH /api/admin/souls/[id]— any subset of the create fields plus"source":"seed"|"api". Semantics: the merged object is re-validated whole; acontentchange recomputescontentHash; aslugchange appends the old slug toslugHistory(old URLs keep 301ing); unknown fields → 422 listing the patchable set. →200 {"soul":{…},"tookOwnership":bool}.
Listings
POST /api/admin/listings— same pattern. Required:slug, name, type, tagline, category, sourceUrl, profileHandle(the authoring profile must already exist —422 unknown_profile; profiles themselves stay git-curated).type:"loop"requiresdata.goal,data.checkCommand,data.exitCondition.GET/PATCH /api/admin/listings/[id]— PATCH may move authorship viaprofileHandle(re-resolved server-side); a type change toloopre-runs the loop-data requirements.
Blog (posts) — the agent write API
POST /api/admin/posts— create. Body withoutid. Required:slug, title, excerpt, category, content. Optional:kind(article|release|link, defaultarticle),description, tags, version, authorHandle(resolved to a profile —422 unknown_profileif absent),listingSlug(resolved to a listing),canonicalUrl,publishedAt,status(draft|pending|published, defaultdraftso an agent post can't go live + get indexed without review). Publishing without apublishedAtstamps now. →201 {"post":{…,"source":"api"}}·409on a taken slug.GET /api/admin/posts— every post, any status, withsource.GET /api/admin/posts/[id]— one post, any status, withsource.PATCH /api/admin/posts/[id]— any subset of the create fields plus"source":"api"|"feed". Re-validated whole;contentchange recomputescontentHash;slugchange appends toslugHistory. Editing afeed-owned post flips it toapi(tookOwnership:true) so feed ingestion stops overwriting it. →200 {"post":{…},"tookOwnership":bool}. Unpublish ={"status":"draft"}(no DELETE).
Feed sources (release/RSS mirroring)
Subscribed feeds the ingestor mirrors into the blog. GitHub releases (kind:"github_release",
feedUrl = the repo URL) become kind:"release" posts with the notes verbatim; RSS/Atom
(kind:"rss", feedUrl = the feed URL) become kind:"link" posts. Each ingested post is
deduped on (feedSourceId, externalId), stamped with the feed's default
authorHandle/listingSlug/category/tags, and either published (when autoPublish:true) or
left pending for /admin/posts review. An admin edit to a feed post flips it to source:"api",
after which re-ingest skips it.
GET /api/admin/feeds— every feed source (joined author handle + listing slug for display).POST /api/admin/feeds— create. Required:label, feedUrl, kind(github_release|rss),defaultCategory(a blog category). Optional:authorHandle(→ profile,422 unknown_profileif absent),listingSlug(→ listing),defaultTags,autoPublish(defaultfalse),enabled(defaulttrue). →201 {"feed":{…}}.GET /api/admin/feeds/[id]— one feed source.PATCH /api/admin/feeds/[id]— any subset of the create fields. No DELETE —{"enabled":false}pauses a feed.POST /api/admin/feeds/[id]/sync— ingest one feed now. →200 {"summary":{…}}, or502if the fetch/parse failed.POST /api/admin/feeds/ingest— ingest every enabled feed (the GitHub Actions cron entry; auth via an adminx-api-key). →200 {feeds,created,updated,skipped,failed,summaries}when all feeds succeeded, or502(same body) when any feed failed — so a scheduled run turns red.
Ownership & deletion
- Any write marks the row
source:"api": the boot seed skips it (loud warning naming the slug) so deploys never clobber admin changes.tookOwnership: truein a PATCH response means a previously git-owned row just flipped — its file insrc/content/is now inert. Hand a row back to git by porting the change and PATCHing{"source":"seed"}. - There is no DELETE verb. Unpublish with
PATCH {"status":"draft"}— hard-deleting a seed-owned row would just be resurrected by the next boot's seed.