SecondMind

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

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 — capture a note
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:

202 Accepted
{
  "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:

Header
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.

Keep keys server-side. A key carries your full account authority — capture, edit, publish, and export all run as you. Put it in an environment variable or a secret store, never in client-side JavaScript or a committed file.

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 are 401; repeated failures from the same email and IP are throttled with 429 for 15 minutes.

  • POST /api/auth/signup Public

    Create an account from {"email", "password", "name"} and sign in, returning 201. Pass team_invite to claim a seat at the same time — a token that is unknown, already used, or issued to a different address is 400. If the account is created but the team join fails you still get 201, plus a teamJoinError field, rather than a lost signup. While signups are closed an uninvited attempt is 403 with {"error": "signups_closed", "waitlist": "/api/waitlist"}.

  • POST /api/auth/logout

    Clear the session and return {"ok": true}. The browser equivalent, GET /logout, redirects to /login instead.

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.

Security headers. Every response carries 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.

FieldTypeNotes
idstringUUID. Pass to /api/jobs/{id}.
kindstringcapture, process-inbox, enrich-note, promote, refresh.
statusstringrunning, done, or error.
messagestringHuman-readable current step.
logsstring[]Appended as the job progresses.
progressnumber | null0–1 when the job can estimate it.
resultobject | nullPopulated once status is done.
errorstring | nullPopulated once status is error.
cancelledbooleanTrue if cancellation was honoured.
createdAt / updatedAtnumberEpoch seconds.
Polling pattern
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. 404 if it has aged out of the in-memory list.

  • POST /api/jobs/{job_id}/cancel

    Request cancellation. 404 if 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.
HTML vs JSON. Requests that look like browser navigation get a redirect to /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 sanitised llm settings, plus authenticated and subscribed for the caller. Works with no credential — the auth fields just come back false.

    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, and llm.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_key value 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 capture job whose result.capture holds the created note.

    Body fieldTypeNotes
    textstringrequiredThe raw content to capture.
    your_takestringoptionalYour own commentary — kept verbatim, never rewritten by the LLM.
    source_urlstringoptionalProvenance recorded in the note's frontmatter.

    Returns 400 if text is 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 with 400.

  • GET /api/inbox/item

    Fetch one inbox item. Query ?label= or ?path= — one of the two is required, else 400.

  • 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 paramTypeNotes
    qstringoptionalFree-text query. Omit to browse.
    categorystringoptionalFilter to one category.
    statusstringoptionalFilter by pipeline status.
    limitintegeroptionalDefault 20, capped at 50. A non-numeric value falls back to 20 rather than erroring.
    curl
    curl -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= (noteId also accepted). Missing both is 400; no match is 404.

  • PUT /api/notes/item

    Write a note's markdown. Body requires both path and content; 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 path or note_id, plus an optional hint (alias your_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 fieldTypeNotes
    querystringone ofPromote from a search across the library.
    note_idstringPromote a specific note by id (noteId also accepted).
    pathstringPromote a specific note by path.
    scopestringoptionalpersonal (default) or project. Anything else is 400.
    queuebooleanoptionalQueue 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 (default top), limit (default 50).

  • GET /api/community/skills/{slug} Public

    One published skill in full, including its author and vote count. 404 if 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 reason and an optional detail.

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, a csrf token for cookie clients, and authViasession or api_key — so you can confirm which credential the server actually resolved. Unauthenticated callers get 401 with {"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 to Default and is truncated to 80 characters. 201 with the plaintext key — 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. 404 if it does not exist, is not yours, or is already revoked.

  • POST /api/account/profile

    Update name, username, or bio. Your username is what /u/{username} resolves to.

  • POST /api/account/password

    Change your password. Requires currentPassword and newPassword. 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. 400 if 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. 503 if 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-Signature header, 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"}. 201 on success, 400 on an invalid address.

Agent manifest. Tools that discover services automatically can read /.well-known/agent.json — it names the auth scheme, the key format, where to obtain one, and the core endpoints, so an agent can wire itself up without reading this page.

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, or subscriptionStatus. Omitted fields are left alone. 400 on an invalid value, 404 if 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 is open.

  • POST /api/admin/community/flags/{flag_id} Admin

    Close out a flag. Body takes status, defaulting to resolved. 404 if 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 to removed. 400 on an unknown status, 404 on an unknown skill.

Ready to wire it up?

Create an account, mint a key from Account, and your first capture is one curl away.