<!-- version: 2026-07-18a -->
# CommonGnd: onboarding for autonomous agents

You're reading this because something handed you one URL and told you to
work with this forum. Everything you need is below. The doc is
self-contained, you don't need the human quickstart at `/docs` first.

> **Base URL.** Replace `${BASE}` in every example with
> `https://commongnd.org/api` (prod) or `http://forum.dev.commongnd.org/api`
> (dev). The `/api` prefix is mandatory: the Next.js layer reverse-proxies
> `/api/*` to the Go backend, so calls to `/auth/...` or `/a2a` at the root
> hit the website and 404.

---

## 0. What this place is, and your role

CommonGnd is a forum and knowledge base for **electronics builders**:
Tesla coils and high voltage, microcontrollers, retro computing and power
electronics. Build logs, questions, driver designs, safety notes, and
record-arc write-ups written by people who build these,
and the best threads get curated into canon.

**Agents are read-only here.** Your job is to read, index, search, and
summarise the forum for the human you work for. You can fetch any thread,
follow the canon, and answer "what does this forum say about X". You
**cannot post**: thread, reply, and vote methods are gated off for agent
(`api_key`) principals and return `403`. This keeps the forum
100% human-authored on purpose, which is what makes it worth indexing.
Don't try to post, and don't schedule yourself to "participate".

If you are a person looking to post, use the site at `commongnd.org` or
the human quickstart at `/docs`, not this doc.

### 0.1 If you're an MCP client (Claude Code / Desktop, Cursor)

A **hosted MCP server** exposes the forum's read surface as MCP tools. For
most tool-using clients this is the easiest path, with no manual token
handling:

```
claude mcp add commongnd --transport http https://api.commongnd.org/mcp/
```

OAuth 2.1 is negotiated automatically on the first call (one browser
handshake). The MCP endpoint is on `api.commongnd.org`, a different host
than this forum (`commongnd.org`), so if you discovered the forum first
you would not find the MCP server without this pointer. The rest of this
doc covers the direct REST / A2A read path for clients without MCP.

---

## 1. Discover: the capability manifest

`GET ${BASE}/.well-known/agent-card.json` returns the A2A 1.0 manifest:
methods, auth schemes, input schemas for every skill, and the streaming
flag. If you have an A2A client library, point it at the host and read the
card. If not, the manifest is still readable JSON, so treat it as the index
of what you can call.

The card publishes `endpoint`, `streamEndpoint`, and `documentationUrl`
already prefixed with `/api/`. Trust them. It also publishes a top-level
`rateLimits` block, `{ window, publicPerMinute, authenticatedPerMinute }`,
so you can pace yourself before hitting a wall (see §5).

---

## 2. Read the forum (no auth required)

Every `list-*` and `get-*` method is **public**: no token, no registration.
Just call it. Two surfaces give you the same data:

- **REST**, discoverable resource paths. Examples: `${BASE}/v1/threads`,
  `${BASE}/v1/threads/{id}`, `${BASE}/v1/categories`, `${BASE}/v1/search`,
  `${BASE}/v1/stats/overview`. See `${BASE}/openapi.json` or `/docs`.
- **A2A JSON-RPC**, a single endpoint where every capability is a method.
  `POST ${BASE}/a2a`:

```json
{ "jsonrpc": "2.0", "method": "list-threads", "params": { "limit": 20, "sort": "recent" }, "id": 1 }
```

Both return the same domain objects. Pick whichever your runtime makes
easier, and you can mix them.

### 2.1 The read methods

| Method (A2A) | REST | What you get |
|---|---|---|
| `list-categories` | `GET /v1/categories` | active categories: slug, name, description |
| `list-threads` | `GET /v1/threads` | recent/hot threads, paginated |
| `get-thread` | `GET /v1/threads/{id}` | one thread with its body |
| `get-threads` | `GET /v1/threads?ids=a,b,c` | bulk-get up to 50 by id, one round-trip |
| `search` | `GET /v1/search?q=...` | full-text + semantic search |
| `get-profile` | `GET /v1/users/{principal}` | a member's public profile and trust level |
| `get-stats-overview` | `GET /v1/stats/overview` | landing dashboard: totals, top tags, category roster |

**Example thread** (so you can shape your parser before the first call).
REST returns the object directly; A2A wraps it in the JSON-RPC `result`:

```json
{
  "id": "0190a3f2-7c1e-7b3a-9f00-2b1c4d5e6f70",
  "slug": "drsstc-iii-racing-sparks-at-the-topload",
  "categorySlug": "coil-types",
  "title": "DRSSTC III: racing sparks at the topload, how do I tame them?",
  "body": "…markdown…",
  "tags": ["drsstc", "racing-sparks", "grounding"],
  "authorPrincipal": "0190a3aa-2222-7000-8000-000000000abc",
  "authorTrustLevel": 3,
  "status": "open",
  "commentCount": 12,
  "viewCount": 340,
  "voteScore": 8.5,
  "createdAt": "2026-07-10T11:02:00Z",
  "lastActivityAt": "2026-07-12T12:40:00Z"
}
```

List endpoints wrap rows in `{"data": [...], "pagination": {"hasMore":
true, "nextCursor": "…"}}`; bulk-get returns `{"data": [...]}` with no
pagination. Errors are always the nested envelope
`{"error": {"code": "<machine_code>", "message": "…"}, "requestId": "…"}`.
Branch on `error.code`, never on the prose `message` (see §6).

### 2.2 Categories and tags

Discover the valid category slugs at runtime via `list-categories` (or
`get-stats-overview.categories[]`); do not guess. Categories are organised
into departments (Tesla coils & high voltage, microcontrollers, retro
computing, power electronics, general electronics) plus cross-cutting
categories (Start Here, Chat, Marketplace, Canon, Suggestions). Each
category carries a `parentSlug` naming its department (null for a
department container or a cross-cutting category).

Tags are cross-cutting facets (`drsstc`, `sstc`, `spark-gap`, `grounding`,
`safety`) matched **case-sensitively**. See the live set under
`get-stats-overview.topTags24h`, and filter with
`list-threads { "tags": ["drsstc"] }` (REST: `GET /v1/threads?tag=drsstc`;
pass several to require all).

### 2.3 Paginate with the cursor

The `pagination.nextCursor` in a list response is **opaque and durable**:
store it as a black-box string, don't parse it, and reuse it across
sessions. Pass it back to get the next page. When `hasMore` is `false` you
have reached the end.

### 2.4 Token-lean reads

`GET ${BASE}/v1/threads/{id}?format=markdown` returns a thread as clean
markdown instead of the full JSON object: fewer tokens when all you need is
the text to summarise.

---

## 3. Optional: register for a higher rate-limit bucket

You never need to authenticate to read. The one reason an indexing agent
might register: authenticated callers are rate-limited **per principal**,
unauthenticated callers **per IP** (see §5). If you share an egress IP with
other traffic, your own principal gets a cleaner budget.

`POST ${BASE}/auth/register`

```json
{ "identityType": "api_key", "displayName": "my-indexer" }
```

Returns `{ principalId, apiKey, accessToken, refreshToken, expiresIn }`.
The `apiKey` (a UUIDv7) is shown **once**, so persist it. Then exchange it
for a JWT when the access token expires:

`POST ${BASE}/oauth/token`

```json
{ "grant_type": "api_key_exchange", "api_key": "<UUIDv7-from-register>" }
```

Returns `{ access_token, refresh_token, token_type, expires_in }` with
`expires_in` in seconds. Re-exchange on a `401` or before expiry; treat
`expires_in` as the source of truth, not a literal you hardcode. Send the
JWT as `Authorization: Bearer <JWT>` on your reads.

Register field names are **camelCase** (`identityType`, `displayName`); the
token endpoint uses **snake_case** (RFC 6749). An agent principal carries a
`read` scope only. Write scopes are never granted to `api_key` principals,
so a `post-*` or `cast-vote` call returns `403` no matter what.

---

## 4. Watch for changes: Server-Sent Events

`GET ${BASE}/a2a/stream` opens a per-principal SSE stream, useful for
keeping an index fresh without polling.

- **Frame format**: `id: <uint64>\nevent: <type>\ndata: <json>\n\n`. Event
  IDs are monotonic per principal.
- **Resume**: on reconnect send `Last-Event-ID: <last-id-you-saw>` as a
  request header. The server replays every event with `id > lastID` from an
  in-memory ring, then continues live.
- **Out-of-window**: if your `Last-Event-ID` is older than the ring
  retention, you silently skip to the oldest event still held. Track the
  highest ID you processed; a gap on resume means you missed events.
- **Process restart**: the ring is in-memory, so a server restart drops all
  rings and a reconnecting agent starts fresh regardless of
  `Last-Event-ID`. Cross-reference your local state if completeness matters.
- **Heartbeat**: every 30 s the server sends `: keepalive\n\n` (an SSE
  comment). Treat its absence after ~60 s as connection death and reconnect.
- **Concurrency cap**: 5 active streams per principal. The 6th returns
  `429` with `too_many_streams`.

---

## 5. Rate limits

Every response on `/v1/*` and `/a2a` carries your current budget so you can
pace yourself:

- `X-RateLimit-Limit`: calls allowed per minute. Authenticated callers are
  bucketed per principal at `authenticatedPerMinute`; unauthenticated by IP
  at `publicPerMinute`. Both are published in the agent-card `rateLimits`
  block.
- `X-RateLimit-Remaining`: calls left in the current window.
- `X-RateLimit-Reset`: unix timestamp when the window refills.

Exceed the budget and you get `429` with `Retry-After: <seconds>` and a
typed body `{"error": {"code": "rate_limited", "retryAfter": <seconds>}}`.
Back off for `retryAfter` seconds; do not hot-loop the retry.

---

## 6. When something breaks

- `401` on an authenticated read: your JWT expired. Re-exchange (§3).
- `403` on a write method: expected. Agents are read-only (§0); there is no
  path to a write scope for an `api_key` principal.
- `400 validation_error` on register: check field casing (camelCase).
- `category_not_found`: re-fetch the live slugs via `list-categories`.
- `rate_limited`: see §5.
- Anything else: the error envelope carries a `requestId`; keep it. A person
  can trace a request by that id.

### 6.1 Versioning

`protocolVersion` in the agent-card is SemVer; pin the MAJOR. `/v1` evolves
additively (new endpoints, new optional fields, new enum values), so
tolerate unknown fields rather than rejecting them. A breaking cut would
land under `/v2`. If a response carries `Deprecation` / `Sunset` headers
(RFC 9745), that endpoint is going away on the `Sunset` date; migrate
before it.

---

## 7. When you're done

When you've read and indexed what you came for, stop. The forum has no
concept of agent "presence": staying connected does nothing for you and
nothing for the forum. Close the loop and exit.
