Developer reference
The SecondMind API
Everything the web app does, your scripts and agents can do too — capture, search, enrich, promote, and export.
- Base URL
https://secondmind.controlgrp.com- Auth header
Authorization: Bearer sm_…- Content type
application/json- Manifest
/.well-known/agent.json
Quickstart
Mint a key from Account, then send it as a Bearer token. Anything you can do in the app, you can do with two lines of shell.
curl -X POST https://secondmind.controlgrp.com/api/capture \
-H "Authorization: Bearer $SECONDMIND_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"text": "Postgres partial indexes only cover rows matching the WHERE clause.",
"your_take": "Use for soft-deleted tables — much smaller index.",
"source_url": "https://www.postgresql.org/docs/current/indexes-partial.html"
}'
Capture is asynchronous, so the response is a job, not the finished note:
{
"id": "5f1c9d0e-…",
"kind": "capture",
"status": "running",
"message": "Capturing with LLM…",
"logs": [],
"progress": null,
"result": null,
"error": null,
"cancelled": false,
"createdAt": 1756051200.41,
"updatedAt": 1756051200.41
}
Poll GET /api/jobs/{id} until status is done (or error), then read result. See Async jobs.
Authentication
The API accepts two credentials. Programmatic clients should always use an API key; browser sessions are what the web app itself uses.
API keys (recommended)
Keys are minted in Account → API keys and look like sm_<43 url-safe chars>. Send one on every request:
Authorization: Bearer sm_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Keys are stored as SHA-256 hashes — the full value is shown once, at creation. Each call updates the key's lastUsedAt, so you can spot a stale or leaked key in Account and revoke it. Revocation takes effect immediately.
Session cookies
Signing in at /login sets a session cookie. Cookie-authenticated mutating requests (POST, PUT) must also carry a CSRF token in the X-CSRF-Token header — endpoints that return csrf in their payload hand you the current one. Bearer requests skip CSRF entirely, which is why keys are the simpler path for automation.
What a key can reach
Most product endpoints require an active Pro subscription as well as a valid credential; without one they answer 402 with a link to billing. Account, billing, and admin routes stay reachable so you can fix that state. A handful of endpoints — marked Public below — need no credential at all.
Session endpoints
These back the sign-in UI. You only need them if you are driving SecondMind with a cookie jar instead of a key — each success returns a csrf token to send back as X-CSRF-Token on later mutations.
-
POST /api/auth/login Public
Exchange
{"email", "password"}for a session cookie. Returns{"ok", "user", "subscribed", "csrf"}. Wrong credentials are401; repeated failures from the same email and IP are throttled with429for 15 minutes. -
POST /api/auth/signup Public
Create an account from
{"email", "password", "name"}and sign in, returning201. Passteam_inviteto claim a seat at the same time — a token that is unknown, already used, or issued to a different address is400. If the account is created but the team join fails you still get201, plus ateamJoinErrorfield, rather than a lost signup. While signups are closed an uninvited attempt is403with{"error": "signups_closed", "waitlist": "/api/waitlist"}. -
POST /api/auth/logout
Clear the session and return
{"ok": true}. The browser equivalent,GET /logout, redirects to/logininstead.
Conventions
Every endpoint speaks JSON in both directions. Request bodies are application/json; query strings carry filters on GET. Timestamps are Unix epoch seconds as floats. Field names in responses are camelCase; request bodies accept snake_case where noted (several also accept the camelCase spelling — for example note_id or noteId).
Responses that mutate state return {"ok": true} alongside the affected object. Long-running work returns 202 and a job. Successful reads return 200 with the resource at the top level.
X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin, and HSTS over TLS. The API is same-origin only — there is no CORS allowance, so call it from a server, not a browser page on another domain.
Async jobs
Anything that calls an LLM — capture, inbox processing, note enrichment, skill promotion, catalog rebuilds — runs in the background. Those endpoints answer 202 Accepted immediately with a job object you poll.
| Field | Type | Notes |
|---|---|---|
id | string | UUID. Pass to /api/jobs/{id}. |
kind | string | capture, process-inbox, enrich-note, promote, refresh. |
status | string | running, done, or error. |
message | string | Human-readable current step. |
logs | string[] | Appended as the job progresses. |
progress | number | null | 0–1 when the job can estimate it. |
result | object | null | Populated once status is done. |
error | string | null | Populated once status is error. |
cancelled | boolean | True if cancellation was honoured. |
createdAt / updatedAt | number | Epoch seconds. |
job=$(curl -s -X POST "$BASE/api/capture" \
-H "Authorization: Bearer $SECONDMIND_API_KEY" \
-H "Content-Type: application/json" \
-d '{"text":"…"}' | jq -r .id)
until [ "$(curl -s "$BASE/api/jobs/$job" \
-H "Authorization: Bearer $SECONDMIND_API_KEY" | jq -r .status)" != "running" ]; do
sleep 2
done
curl -s "$BASE/api/jobs/$job" -H "Authorization: Bearer $SECONDMIND_API_KEY" | jq .result
Jobs live in memory on the server. The 20 most recent are listed by GET /api/jobs; poll on a loop rather than caching an id for later.
-
GET /api/jobs
Recent and active jobs:
{"jobs": […20 most recent], "active": […]}. -
GET /api/jobs/active
Only jobs still running — cheap to poll when you just want to know whether the pipeline is busy.
-
GET /api/jobs/{job_id}
One job by id.
404if it has aged out of the in-memory list. -
POST /api/jobs/{job_id}/cancel
Request cancellation.
404if the job is unknown or already past the point where it can stop.
Errors
Failures return the matching HTTP status and a body of {"error": "<reason>"}. Some add a pointer field — login or billing — with the path a human should visit to resolve it.
- 400Bad request — a required field is missing, or the LLM is not configured for an endpoint that needs it.
- 401
{"error": "unauthorized", "login": "/login"}— missing, malformed, or revoked credential. - 402
{"error": "subscription_required", "billing": "/billing"}— authenticated, but no active Pro subscription. - 403Forbidden — admin-only route, or
{"error": "csrf_invalid"}on a cookie-authenticated mutation. - 404No such note, skill, agent, key, or job.
- 429Too many failed sign-in attempts for that email and IP. Cools off after 15 minutes.
- 503A feature is not configured on this deployment — currently only donations return this.
/login or /billing instead of a JSON error. Send Accept: application/json (or use a /api/ path, which always does) to guarantee a JSON body you can parse.
System
Status and configuration. Use /api/health as your preflight check — it tells you whether your credential resolved, whether the subscription is active, and whether the LLM backing the async endpoints is reachable.
-
GET /api/health Public
Service state. Returns
llmConfigured,llmConnected,llmStatus, the sanitisedllmsettings, plusauthenticatedandsubscribedfor the caller. Works with no credential — the auth fields just come backfalse.200 OK{ "ok": true, "llmConfigured": true, "llmConnected": true, "llmStatus": { "ok": true, "state": "ready", "message": "" }, "llm": { "base_url": "…", "model": "…", "timeout": 120 }, "authRequired": true, "authenticated": true, "subscribed": true } -
GET /api/llm/status
Live connectivity test against the configured LLM endpoint.
-
POST /api/llm/test
Test a candidate configuration before saving it. Body:
{"llm": {"base_url", "model", "timeout", "api_key"}}— any omitted field falls back to the saved value. -
POST /api/llm/models
List models the configured provider exposes. Body takes
llm.base_url,llm.timeout, andllm.api_key. -
GET /api/settings
Current settings with the API key redacted.
-
PUT /api/settings
Update LLM settings. Send only the keys you want changed; a masked
api_keyvalue is ignored rather than overwriting the stored one. -
GET /api/dashboard
The full dashboard payload — pipeline counts, recent activity, and pending inbox items.
-
POST /api/refresh 202 · Job
Regenerate every catalog from disk. Useful after editing your vault outside SecondMind.
Capture & inbox
Capture is the front door: hand it raw text and the LLM structures it into a library note. The inbox holds items that arrived without being processed yet.
-
POST /api/capture 202 · Job
Structure raw text into a note. Returns a
capturejob whoseresult.captureholds the created note.Body field Type Notes textstring required The raw content to capture. your_takestring optional Your own commentary — kept verbatim, never rewritten by the LLM. source_urlstring optional Provenance recorded in the note's frontmatter. Returns
400iftextis empty or the LLM is not configured. -
POST /api/process-inbox 202 · Job
Run the LLM over pending inbox items. Optional body
{"labels": ["…"]}restricts the run to specific items; anything other than an array is rejected with400. -
GET /api/inbox/item
Fetch one inbox item. Query
?label=or?path=— one of the two is required, else400. -
PUT /api/inbox/item
Overwrite an inbox item's content before processing it.
-
POST /api/inbox/archive
Archive an item without promoting it into the library.
-
POST /api/inbox/save
Drop raw text straight into the inbox for later processing — the fast path when you want to save now and structure later.
Library & notes
Your processed knowledge. Search it, read individual notes, edit them in place, or hand one back to the LLM for enrichment.
-
GET /api/library/search
Search the library. Returns
{"query", "count", "results"}.Query param Type Notes qstring optional Free-text query. Omit to browse. categorystring optional Filter to one category. statusstring optional Filter by pipeline status. limitinteger optional Default 20, capped at50. A non-numeric value falls back to 20 rather than erroring.curlcurl -G https://secondmind.controlgrp.com/api/library/search \ -H "Authorization: Bearer $SECONDMIND_API_KEY" \ --data-urlencode "q=partial index" \ --data-urlencode "limit=5" -
GET /api/notes/catalog
The whole notes catalog — every note's metadata in one payload. Good for building your own index.
-
GET /api/notes/item
One note in full. Query
?path=or?note_id=(noteIdalso accepted). Missing both is400; no match is404. -
PUT /api/notes/item
Write a note's markdown. Body requires both
pathandcontent; this replaces the file wholesale, so read first if you are patching. -
POST /api/notes/enrich 202 · Job
Send a note back through the LLM to deepen it. Body takes
pathornote_id, plus an optionalhint(aliasyour_take) steering what to expand on.
Skills & community
Promotion turns a note or a query into a reusable agent skill. Published skills land in the community marketplace, which is readable without an account.
-
POST /api/promote 202 · Job
Promote knowledge into a skill.
Body field Type Notes querystring one of Promote from a search across the library. note_idstring Promote a specific note by id ( noteIdalso accepted).pathstring Promote a specific note by path. scopestring optional personal(default) orproject. Anything else is400.queueboolean optional Queue for review instead of writing the skill immediately. -
GET /api/skills/vault
Your own skills — the private vault, including skills forked from the community.
-
GET /api/community/skills Public
Browse published skills. Query params:
q,category,sort(defaulttop),limit(default50). -
GET /api/community/skills/{slug} Public
One published skill in full, including its author and vote count.
404if the slug is unknown or the skill is unpublished. -
GET /api/community/profile/{username} Public
A contributor's public profile: bio, badges, and published skills.
-
POST /api/community/skills
Publish a skill from your vault to the marketplace.
-
POST /api/community/skills/{skill_id}/vote
Cast or clear a vote on a published skill.
-
POST /api/community/skills/{skill_id}/fork
Copy a community skill into your own vault, where you can edit it freely.
-
POST /api/community/skills/{skill_id}/flag
Report a skill for moderation. Body takes
reasonand an optionaldetail.
Agents
An agent is a named bundle of instructions plus attached skills. Export gives you the whole thing as a portable bundle — nothing here is locked in.
-
GET /api/agents
Your agents, each with its attached skills.
-
POST /api/agents
Create an agent.
-
GET /api/agents/{agent_id}
One agent with its skill links.
-
POST /api/agents/{agent_id}
Update an agent's fields. Owner only.
-
POST /api/agents/{agent_id}/skills
Attach a skill to the agent. The response includes the new link's id.
-
POST /api/agents/{agent_id}/skills/{link_id}/detach
Remove a skill from the agent. Detaching never deletes the skill itself.
-
GET /api/agents/{agent_id}/export
Export the full bundle — agent config, instructions, and every attached skill's markdown — as JSON you can run anywhere.
Account & keys
Key management is itself an API, so you can rotate credentials from a script. Note that creating a key requires an active subscription, while listing and revoking do not — you can always clean up.
-
GET /api/auth/me
The current user plus
subscribed, acsrftoken for cookie clients, andauthVia—sessionorapi_key— so you can confirm which credential the server actually resolved. Unauthenticated callers get401with{"authenticated": false}, which makes this a cheap way to validate a key. -
GET /api/account/keys
All keys including revoked ones —
id,name,prefix,createdAt,lastUsedAt,revokedAt,revoked. Never the key itself. -
POST /api/account/keys
Mint a key. Optional body
{"name": "ci-runner"}; the name defaults toDefaultand is truncated to 80 characters.201with the plaintextkey— the only time it is ever returned.201 Created{ "id": 4, "name": "ci-runner", "prefix": "sm_7Qa2Vx", "createdAt": 1756051200.41, "key": "sm_7Qa2Vx…", "hint": "Copy now — the full key is shown only once." } -
POST /api/account/keys/{key_id}/revoke
Revoke a key immediately.
404if it does not exist, is not yours, or is already revoked. -
POST /api/account/profile
Update
name,username, orbio. Your username is what/u/{username}resolves to. -
POST /api/account/password
Change your password. Requires
currentPasswordandnewPassword. Session cookie only — this one is deliberately not reachable with an API key.
Teams
Team plans share one subscription across seats. Invite lookup is deliberately open so an invitee can see what they are joining before they have an account.
-
GET /api/team
Your team: members, open invites, and seat usage.
-
POST /api/team/invite
Invite someone to a seat. Owner only.
-
GET /api/team/invite/{token} Public
Look up an invite by token before signing up.
-
POST /api/team/invite/{token}/accept
Accept an invite and claim the seat. Requires an account.
-
POST /api/team/invite/{invite_id}/revoke
Withdraw an invite that has not been accepted.
-
POST /api/team/member/{member_id}/remove
Release a seat. Owner only.
Pricing & billing
Pricing is readable without an account, which is what powers the public pricing page. Checkout and portal flows return Stripe URLs for a human to complete in a browser — they are not scriptable end-to-end by design.
-
GET /api/pricing Public
Plans, amounts, trial length, and feature lists. Adds
authenticated,subscribed, and a CSRF token when a session is present. -
GET /api/billing/config Public
Publishable billing configuration for the checkout UI.
-
POST /api/billing/checkout
Start a subscription. Returns a Stripe Checkout URL to redirect to.
-
POST /api/billing/portal
Returns a Stripe billing-portal URL for managing payment method and invoices.
-
POST /api/billing/cancel
Cancel the active subscription.
-
POST /api/billing/retain
Step one of cancellation: applies a one-time 50% discount to the next invoice as a retention offer.
400if there is no active subscription. The app calls this before/api/billing/cancel, and the offer can only be taken once. -
POST /api/billing/donate Public
Open a one-off donation checkout, returning
{"url"}. Works signed out — pass{"email"}, which must contain an@, or omit it when a session is present.503if donations are not configured on the deployment. -
POST /api/billing/webhook Stripe only
Inbound Stripe events. Listed for completeness — it takes a raw body verified against the
Stripe-Signatureheader, not JSON, and is exempt from both auth and CSRF because Stripe calls it. Nothing you write should post here. -
POST /api/waitlist Public
Join the waitlist with
{"email", "name"}.201on success,400on an invalid address.
Admin
These require role: "admin" on top of a valid credential, and answer 403 {"error": "forbidden"} otherwise. They are documented so operators can script moderation and account chores; they are not part of the product surface for ordinary accounts.
-
GET /api/admin/users Admin
Every user account, plus a CSRF token.
-
POST /api/admin/users/{user_id} Admin
Set a user's
role,plan, orsubscriptionStatus. Omitted fields are left alone.400on an invalid value,404if the user does not exist. -
GET /api/admin/waitlist Admin
Everyone who has joined the waitlist through
POST /api/waitlist. -
GET /api/admin/community/flags Admin
Moderation queue. Query
?status=filters it; the default isopen. -
POST /api/admin/community/flags/{flag_id} Admin
Close out a flag. Body takes
status, defaulting toresolved.404if the flag is unknown. -
GET /api/admin/community/skills Admin
The 100 newest published skills including ones hidden from the public marketplace — the moderation view of
/api/community/skills. -
POST /api/admin/community/skills/{skill_id} Admin
Set a published skill's
status, defaulting toremoved.400on an unknown status,404on an unknown skill.
Ready to wire it up?
Create an account, mint a key from Account, and your first capture is one curl away.