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 raw GET /api/souls/[slug].md install endpoint.
  • installCmd — the ready-to-run curl <mdUrl> > ~/.hermes/SOUL.md line.

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.
  • 404 with 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_type with 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_input naming 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 with publishedAt > since), limit (1–100, default 20). Bad values → 400 invalid_input.
  • latest is the newest publishedAt within the same filter (kind/category/tag/q) — use it as the next since to poll only the delta. A ResearchItem is 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).

  1. POST /api/auth/device/code body {"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, default positronick-cli).
  2. 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.
  3. CLI polls POST /api/auth/device/token body {"grant_type":"urn:ietf:params:oauth:grant-type:device_code","device_code":"…","client_id":"positronick-cli"}:
    • {"error":"authorization_pending"} → keep polling every interval s
    • {"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 — defaultExpiresIn is 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. The posi_ 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, mode 0600.
  • POSITRONICK_API_KEY env 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 without id (server-assigned ULID; a client id → 422). Required: slug, name, authorHandle, tagline, category, frameworks[], version, license, content. Optional: authorName, authorUrl, description, tags, models, repoUrl, slugHistory, status (draft|pending|published, default published). → 201 {"soul":{…,"source":"api"}} · 409 conflict on a taken slug.
  • GET /api/admin/souls/[id] — any status, includes source.
  • PATCH /api/admin/souls/[id] — any subset of the create fields plus "source":"seed"|"api". Semantics: the merged object is re-validated whole; a content change recomputes contentHash; a slug change appends the old slug to slugHistory (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" requires data.goal, data.checkCommand, data.exitCondition.
  • GET/PATCH /api/admin/listings/[id] — PATCH may move authorship via profileHandle (re-resolved server-side); a type change to loop re-runs the loop-data requirements.

Blog (posts) — the agent write API

  • POST /api/admin/posts — create. Body without id. Required: slug, title, excerpt, category, content. Optional: kind (article|release|link, default article), description, tags, version, authorHandle (resolved to a profile — 422 unknown_profile if absent), listingSlug (resolved to a listing), canonicalUrl, publishedAt, status (draft|pending|published, default draft so an agent post can't go live + get indexed without review). Publishing without a publishedAt stamps now. → 201 {"post":{…,"source":"api"}} · 409 on a taken slug.
  • GET /api/admin/posts — every post, any status, with source.
  • GET /api/admin/posts/[id] — one post, any status, with source.
  • PATCH /api/admin/posts/[id] — any subset of the create fields plus "source":"api"|"feed". Re-validated whole; content change recomputes contentHash; slug change appends to slugHistory. Editing a feed-owned post flips it to api (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_profile if absent), listingSlug (→ listing), defaultTags, autoPublish (default false), enabled (default true). → 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":{…}}, or 502 if the fetch/parse failed.
  • POST /api/admin/feeds/ingest — ingest every enabled feed (the GitHub Actions cron entry; auth via an admin x-api-key). → 200 {feeds,created,updated,skipped,failed,summaries} when all feeds succeeded, or 502 (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: true in a PATCH response means a previously git-owned row just flipped — its file in src/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.