# IndieBob for agents

IndieBob is a growth engine for indie software developers: it writes and
publishes content, runs cold outreach where every email is written for that
one prospect, tracks what each piece earned, and ranks what to do next.

**Every capability below runs with an API key alone — no browser session, no
human click.** That is what makes an agent able to run a launch here rather
than merely read about one.

Machine-readable form of this document: `https://www.indiebob.com/.well-known/agents.json`.

## Authentication

```
Authorization: Bearer ib_sk_<64 hex characters>
```

Keys are minted by the account owner (see `https://www.indiebob.com/docs/agents`) with an
explicit scope list, an expiry, and one-time display — the raw key is stored
only as a SHA-256 hash and cannot be recovered.

| Status | Meaning |
|---|---|
| `401` | No, malformed, expired or revoked key. Rotate. |
| `403` | Valid key, missing scope. The message names the scope — ask for exactly that one. |
| `429` | A rate or plan limit refused the whole call. Nothing was partially done. |

Scopes: `read:projects`, `read:metrics`, `read:revenue`, `read:audience`, `read:content`, `write:content`, `read:admin`, `read:outreach`, `write:outreach`, `read:actions`.

A key for everything on this page needs: `read:projects`, `read:metrics`, `read:actions`, `read:content`, `write:content`, `read:outreach`, `write:outreach`.

## MCP

The same capabilities are MCP tools, one stdio process, no hosting:

```json
{
  "mcpServers": {
    "indiebob": {
      "command": "npx",
      "args": [
        "-y",
        "@indiebob/mcp"
      ],
      "env": {
        "INDIEBOB_API_KEY": "ib_sk_…",
        "INDIEBOB_BASE_URL": "https://www.indiebob.com"
      }
    }
  }
}
```

Tool names are the capability ids below.

## Capabilities

| Tool | Endpoint | Scope |
|---|---|---|
| [`whoami`](#whoami) | `GET /api/v1` | `read:projects` |
| [`list_projects`](#list_projects) | `GET /api/v1/projects` | `read:projects` |
| [`read_metrics`](#read_metrics) | `GET /api/v1/metrics` | `read:metrics` |
| [`next_actions`](#next_actions) | `GET /api/v1/actions` | `read:actions` |
| [`list_posts`](#list_posts) | `GET /api/v1/content/posts` | `read:content` |
| [`get_post`](#get_post) | `GET /api/v1/content/posts/{postId}` | `read:content` |
| [`create_post`](#create_post) | `POST /api/v1/content/posts` | `write:content` |
| [`publish_post`](#publish_post) | `POST /api/v1/content/posts/{postId}/publish` | `write:content` |
| [`list_prospects`](#list_prospects) | `GET /api/v1/outreach/prospects` | `read:outreach` |
| [`list_sequences`](#list_sequences) | `GET /api/v1/outreach/sequences` | `read:outreach` |
| [`enroll_prospects`](#enroll_prospects) | `POST /api/v1/outreach/sequences/{sequenceId}/enroll` | `write:outreach` |
| [`list_review_queue`](#list_review_queue) | `GET /api/v1/outreach/messages` | `read:outreach` |
| [`approve_message`](#approve_message) | `POST /api/v1/outreach/messages/{messageId}/approve` | `write:outreach` |

### whoami

**Check the key** — Confirm the key is live and list the scopes it holds. Returns no portfolio data, so it is safe to call first.

`GET https://www.indiebob.com/api/v1` · scope `read:projects`

_No parameters._

Returns: { ok, version, keyId, scopes[], endpoints{} }

```bash
curl -H "Authorization: Bearer $INDIEBOB_API_KEY" https://www.indiebob.com/api/v1
```

```json
{"ok":true,"version":"v1","keyId":"…","scopes":["read:projects","read:outreach"]}
```

### list_projects

**List projects** — Every project the key owner has, with health status, stage and settings. Start here: most other capabilities take a project id or slug.

`GET https://www.indiebob.com/api/v1/projects` · scope `read:projects`

| Param | In | Type | Meaning |
|---|---|---|---|
| `updated_since` | query | string | ISO timestamp; only projects changed at or after it. |
| `include_archived` | query | boolean | true to include archived projects (default: exclude). |
| `limit` | query | number | 1–200, default 100. |

Returns: { data[], count, nextUpdatedSince }

```bash
curl -H "Authorization: Bearer $INDIEBOB_API_KEY" https://www.indiebob.com/api/v1/projects
```

```json
{"data":[{"id":"…","name":"maimu","slug":"maimu","healthStatus":"watch"}],"count":1}
```

### read_metrics

**Read metrics** — Daily snapshots per project: MRR, visitors, signups, revenue, health. Date-windowed, not cursor-based, because snapshots are restated when late Stripe events land — re-pull a trailing window and upsert.

`GET https://www.indiebob.com/api/v1/metrics` · scope `read:metrics`

| Param | In | Type | Meaning |
|---|---|---|---|
| `project` | query | string | Project id or slug. Resolved inside the key owner's portfolio; an unowned value matches nothing rather than 404ing. |
| `from` | query | string | YYYY-MM-DD, inclusive. Default: 90 days ago. |
| `to` | query | string | YYYY-MM-DD, inclusive. Default: today. |
| `limit` | query | number | 1–200, default 100. |

Returns: { data[], count } — one row per project per day

```bash
curl -H "Authorization: Bearer $INDIEBOB_API_KEY" "https://www.indiebob.com/api/v1/metrics?project=maimu&from=2026-09-01"
```

```json
{"data":[{"projectId":"…","date":"2026-09-01","mrrCents":0,"uniqueVisitors":34,"signups":2}],"count":1}
```

### next_actions

**What should I do next** — The ranked Do Next feed across the whole portfolio — the same rules the Command Center runs, highest priority first, each with why it fired and where it leads. Deterministic: no model runs, so the answer is the same for the same data.

`GET https://www.indiebob.com/api/v1/actions` · scope `read:actions`

| Param | In | Type | Meaning |
|---|---|---|---|
| `project` | query | string | Project id or slug. Resolved inside the key owner's portfolio; an unowned value matches nothing rather than 404ing. |
| `limit` | query | number | 1–50, default 10. |

Returns: { actions[{ id, projectId, projectName, kind, priority, title, detail, cta{label,url}, metric? }], count }

```bash
curl -H "Authorization: Bearer $INDIEBOB_API_KEY" https://www.indiebob.com/api/v1/actions
```

```json
{"actions":[{"id":"…:outreach_awaiting_review","kind":"risk","priority":92,"title":"3 first touches wait for your yes"}],"count":1}
```

### list_posts

**List blog posts** — Blog posts in the key owner's projects, newest-updated first, with status and SEO score. Bodies are not included — use get_post.

`GET https://www.indiebob.com/api/v1/content/posts` · scope `read:content`

| Param | In | Type | Meaning |
|---|---|---|---|
| `project` | query | string | Project id or slug. Resolved inside the key owner's portfolio; an unowned value matches nothing rather than 404ing. |
| `status` | query | string | draft | published | archived. Default: everything except archived. |
| `limit` | query | number | 1–200, default 100. |

Returns: { posts[], count }

```bash
curl -H "Authorization: Bearer $INDIEBOB_API_KEY" "https://www.indiebob.com/api/v1/content/posts?status=draft"
```

```json
{"posts":[{"id":"…","title":"How I run outreach on one GPU","status":"draft","seoScore":71}],"count":1}
```

### get_post

**Read one blog post** — One post with its markdown body and SEO fields. A post outside the key owner's portfolio 404s exactly like a nonexistent one.

`GET https://www.indiebob.com/api/v1/content/posts/{postId}` · scope `read:content`

| Param | In | Type | Meaning |
|---|---|---|---|
| `postId` | path | string, required | Post id. |

Returns: { post } — list fields plus bodyMarkdown, metaTitle, metaDescription, ogImageUrl

```bash
curl -H "Authorization: Bearer $INDIEBOB_API_KEY" https://www.indiebob.com/api/v1/content/posts/POST_ID
```

```json
{"post":{"id":"…","title":"…","bodyMarkdown":"# …","status":"draft"}}
```

### create_post

**Draft a blog post** — Create a draft. Markdown is the source of truth — HTML cannot be submitted, it is rendered server-side. The slug is derived from the title.

`POST https://www.indiebob.com/api/v1/content/posts` · scope `write:content`

| Param | In | Type | Meaning |
|---|---|---|---|
| `project` | body | string, required | Project id or slug, inside the key owner's portfolio. |
| `title` | body | string, required | Post title. |
| `bodyMarkdown` | body | string | Markdown body. |
| `excerpt` | body | string | Short summary. |
| `metaTitle` | body | string | SEO title. |
| `metaDescription` | body | string | SEO description. |

Returns: { post }

```bash
curl -X POST -H "Authorization: Bearer $INDIEBOB_API_KEY" -H "content-type: application/json" -d '{"project":"maimu","title":"Launch week","bodyMarkdown":"# Launch week"}' https://www.indiebob.com/api/v1/content/posts
```

```json
{"post":{"id":"…","slug":"launch-week","status":"draft"}}
```

### publish_post

**Publish a blog post** — Publish or re-publish. THE propagation trigger: it snapshots the body, assigns the OG image and fires the content webhook that pushes the post to mirrored sites. Nothing else takes a post live.

`POST https://www.indiebob.com/api/v1/content/posts/{postId}/publish` · scope `write:content`

| Param | In | Type | Meaning |
|---|---|---|---|
| `postId` | path | string, required | Post id. |

Returns: { post } with status=published and publishedAt set

```bash
curl -X POST -H "Authorization: Bearer $INDIEBOB_API_KEY" https://www.indiebob.com/api/v1/content/posts/POST_ID/publish
```

```json
{"post":{"id":"…","status":"published","publishedAt":"2026-09-07T20:00:00.000Z"}}
```

### list_prospects

**List prospects** — Cold-outreach prospects for one project, newest first, with status, source and the facts enrichment read off their own website.

`GET https://www.indiebob.com/api/v1/outreach/prospects` · scope `read:outreach`

| Param | In | Type | Meaning |
|---|---|---|---|
| `project` | query | string, required | Project id or slug. Resolved inside the key owner's portfolio; an unowned value matches nothing rather than 404ing. |
| `status` | query | string | new | contacted | replied | bounced | rejected. Default: everything but archived. |
| `source` | query | string | Where the prospect came from, e.g. launch_venues, import, manual. |
| `search` | query | string | Substring match on email, company or name. |
| `limit` | query | number | 1–200, default 50. |

Returns: { prospects[{ id, email, firstName, company, website, status, source, tags }], count, counts{byStatus,bySource} }

```bash
curl -H "Authorization: Bearer $INDIEBOB_API_KEY" "https://www.indiebob.com/api/v1/outreach/prospects?project=maimu&status=new"
```

```json
{"prospects":[{"id":"…","email":"dev@example.com","company":"Example","status":"new","source":"launch_venues"}],"count":1}
```

### list_sequences

**List outreach sequences** — The project's sequences with their status, step count, whether each is personalised (Bob writes every email per prospect) and how many prospects are enrolled.

`GET https://www.indiebob.com/api/v1/outreach/sequences` · scope `read:outreach`

| Param | In | Type | Meaning |
|---|---|---|---|
| `project` | query | string, required | Project id or slug. Resolved inside the key owner's portfolio; an unowned value matches nothing rather than 404ing. |

Returns: { sequences[{ id, name, status, personalised, stepCount, enrolledCount }], count }

```bash
curl -H "Authorization: Bearer $INDIEBOB_API_KEY" "https://www.indiebob.com/api/v1/outreach/sequences?project=maimu"
```

```json
{"sequences":[{"id":"…","name":"German restaurants","status":"active","personalised":true,"stepCount":3,"enrolledCount":42}],"count":1}
```

### enroll_prospects

**Enrol prospects in a sequence** — Enrol up to 200 prospects. Unsubscribed, rejected and already-enrolled prospects are skipped and counted back, never silently dropped. On a personalised sequence this opens each first touch for Bob to write; if the plan has fewer emails left than prospects, the whole call is refused with 429 rather than parking them behind a limit.

`POST https://www.indiebob.com/api/v1/outreach/sequences/{sequenceId}/enroll` · scope `write:outreach`

| Param | In | Type | Meaning |
|---|---|---|---|
| `sequenceId` | path | string, required | Sequence id. |
| `project` | body | string, required | Project id or slug owning the sequence. |
| `prospectIds` | body | string[], required | 1–200 prospect ids from list_prospects. |

Returns: { enrolled, skippedUnsubscribed, skippedRejected, skippedDuplicate, personalised }

```bash
curl -X POST -H "Authorization: Bearer $INDIEBOB_API_KEY" -H "content-type: application/json" -d '{"project":"maimu","prospectIds":["…"]}' https://www.indiebob.com/api/v1/outreach/sequences/SEQ_ID/enroll
```

```json
{"enrolled":1,"skippedUnsubscribed":0,"skippedRejected":0,"skippedDuplicate":0,"personalised":true}
```

### list_review_queue

**Read the review queue** — The personalised first touches waiting for a human yes — each with the subject and opening Bob wrote for that prospect, and the prospect it is addressed to. This is what a human approves in the dashboard; an agent can read the same rows.

`GET https://www.indiebob.com/api/v1/outreach/messages` · scope `read:outreach`

| Param | In | Type | Meaning |
|---|---|---|---|
| `project` | query | string, required | Project id or slug. Resolved inside the key owner's portfolio; an unowned value matches nothing rather than 404ing. |
| `sequenceId` | query | string | Narrow to one sequence. |
| `state` | query | string | Message state; default awaiting_review — the review queue itself. |
| `limit` | query | number | 1–200, default 50. |

Returns: { messages[{ id, state, subject, opening, dueAt, prospect{ id, email, company }, sequence{ id, name } }], counts{}, count }

```bash
curl -H "Authorization: Bearer $INDIEBOB_API_KEY" "https://www.indiebob.com/api/v1/outreach/messages?project=maimu"
```

```json
{"messages":[{"id":"…","state":"awaiting_review","subject":"Your menu, in three languages","prospect":{"email":"dev@example.com"}}],"count":1}
```

### approve_message

**Approve a first touch** — The yes on one personalised first touch. Sending window, daily caps and the sequence's sending account still apply afterwards — approving does not send. A message that is not awaiting review answers 409 saying what state it is in.

`POST https://www.indiebob.com/api/v1/outreach/messages/{messageId}/approve` · scope `write:outreach`

| Param | In | Type | Meaning |
|---|---|---|---|
| `messageId` | path | string, required | Message id from list_review_queue. |
| `project` | body | string, required | Project id or slug owning the message. |

Returns: { ok, id, state: "approved" }

```bash
curl -X POST -H "Authorization: Bearer $INDIEBOB_API_KEY" -H "content-type: application/json" -d '{"project":"maimu"}' https://www.indiebob.com/api/v1/outreach/messages/MSG_ID/approve
```

```json
{"ok":true,"id":"…","state":"approved"}
```

## What is deliberately not here

Billing, key minting, project creation, settings and every administrative
mutation stay in the browser. An agent can run the growth loop; it cannot
change what the account is or what it pays. Sending is the same shape: an
agent can approve a first touch, and the send still obeys the sequence's
window, daily cap and sending account.

_Generated from IndieBob's capability registry on 2026-09-08. This file is not hand-written; it is rendered from the code that serves these endpoints._
