API
Everything the console can do, for a server or an agent. Version 1.0.0.
Machine-readable: /v1/openapi.json
· /llms.txt
MCP
The same operations, as tools. Point an MCP client at
https://openmic.dev/mcp — or at
https://mcp.openmic.dev, which serves the same thing at its root.
Streamable HTTP, no installation, and either credential below.
Each tool carries the scope it needs and what it costs in its description,
so an agent can tell that publish_episode emails people before
it calls it rather than afterwards.
Both protocol eras are spoken on the one endpoint: the stateless
2026-07-28 revision, and the older initialize-based
ones that most clients still use. Nothing is stored between requests either
way, so which one you speak is a property of the request rather than of the
connection.
- /v1/branding
- /v1/branding
- /v1/episodes
- /v1/episodes/{slug}
- /v1/episodes/{slug}/publish
- /v1/episodes/{slug}/themes
- /v1/featured
- /v1/featured
- /v1/questions
- /v1/questions/{question_id}
- /v1/space
- /v1/themes
Authentication
Two kinds of credential, both sent the same way. They answer different questions: a key says which space may this server touch, a token says which person is this client acting for.
A secret key
curl https://openmic.dev/v1/space \ -H "Authorization: Bearer sk_your_key"
Create one in the console under Keys. A key is shown once,
is scoped — read then write then
admin, each including the ones before it — and can be revoked.
It belongs to exactly one space and can never reach another. This is the
right credential for a server with nobody at a keyboard.
The pk_ key in your embed snippet is a different thing: it is
public by design, it only tells a browser where to point the widget, and it
does not authenticate here.
Or sign the person in
A client can authorize as its user instead, with OAuth 2.1 — no key to copy
and nothing secret in a config file. An unauthenticated
tools/call answers 401 with a
WWW-Authenticate header naming the document to start from:
https://mcp.openmic.dev/.well-known/oauth-protected-resource https://openmic.dev/.well-known/oauth-authorization-server
Authorization code with PKCE, S256 only. The person signs in
to Open Mic, chooses one space, and approves — and the
token carries no more than their own membership allows, so an editor cannot
hand over admin however much the client asked for. Both the
owner and whoever approved it can withdraw it from the same
Keys page, and it stops working immediately.
initialize, tools/list, ping and
server/discover take no credential, so a client can see what is
on offer before deciding to authorize. Only tools/call
challenges — which means a client connects successfully and is sent through
the browser on its first actual tool call, rather than on connect.
Nothing has to be arranged with us first. Dynamic client registration
(RFC 7591) is open at https://openmic.dev/o/register/ and
advertised in the document above, so a client discovers the 401, registers
itself, and runs the flow — ten registrations an hour per address. A
registration is a name, not access: it reaches nothing until a
person approves it.
Connecting a client
claude mcp add --transport http openmic https://mcp.openmic.dev/
That is the whole thing. Then use a tool, approve in the browser, and it works. /docs/mcp/ has the same for Cursor, VS Code, Claude Desktop and the rest, and is the page to send somebody who wants to connect rather than to integrate.
Errors
Every error has the same shape, including 404 and 405. Branch on
code; message is for a human reading logs.
{"error": {"code": "invalid_credential", "message": "That key is not valid."}}
Paging
Lists take limit (max 100) and offset, and return
total — the number of matching rows, not the number on this
page. Values out of range are clamped rather than refused, so a paging loop
cannot be broken by asking for too much.
Endpoints
/v1/branding
The space's resolved appearance. Defaults with the space's overrides applied, not the raw config — which is mostly absent for a space that has changed nothing.
| Returns | Type | |
|---|---|---|
| colors | object | Hex values by role: bg, surface, text, muted, border, accent, accentText, error. |
| colors_dark | object | The same roles for dark mode. Used when theme_mode is dark, or auto on a device that prefers it. |
| copy | object | The words the widget uses — including anonymousName, what an unnamed asker is called. |
| font | object | family (the body typeface), displayFamily (headings, falling back to family when empty), url (a stylesheet to load) and scale (a multiplier on the base size). |
| radius | integer | Corner radius in px: 0, 8, 16 or 999. |
| show | object | Per-surface switches. These can only ever withhold: turning askerName off hides every name, and turning it on cannot reveal one the asker withheld. |
| theme_mode | string | light | dark | auto. |
/v1/branding
Change appearance. Only what is sent changes. Colours are validated on the way in, not at render.
| Body | Type | |
|---|---|---|
| colors | object | Partial: merged over what is set. |
| colors_dark | object | Partial: merged over what is set. |
| copy | object | Partial: merged. |
| font | object | Partial: merged. Keys are family, displayFamily, url and scale — an unrecognised key is refused rather than stored, because a key nothing reads is a 200 that changes nothing. |
| radius | integer | 0, 8, 16 or 999. |
| show | object | Partial: merged. |
| theme_mode | string | light | dark | auto. |
| Returns | Type | |
|---|---|---|
| colors | object | Hex values by role: bg, surface, text, muted, border, accent, accentText, error. |
| colors_dark | object | The same roles for dark mode. Used when theme_mode is dark, or auto on a device that prefers it. |
| copy | object | The words the widget uses — including anonymousName, what an unnamed asker is called. |
| font | object | family (the body typeface), displayFamily (headings, falling back to family when empty), url (a stylesheet to load) and scale (a multiplier on the base size). |
| radius | integer | Corner radius in px: 0, 8, 16 or 999. |
| show | object | Per-surface switches. These can only ever withhold: turning askerName off hides every name, and turning it on cannot reveal one the asker withheld. |
| theme_mode | string | light | dark | auto. |
/v1/episodes
Published episodes, newest first. Drafts are not episodes: they have no public URL.
| Parameter | Type | In | Meaning |
|---|---|---|---|
| theme | string | query | Theme slug. |
| limit | integer | query | Max 100. Default 25. |
| offset | integer | query | Rows to skip. |
| Returns | Type | |
|---|---|---|
| episodes | EpisodeOut[] | |
| asker | Asker | Who asked, subject to their consent. |
| display_name | string | What a page would call them — a name if they consented to one being shown, otherwise the space's word for an unnamed asker. |
| is_identified | boolean | Whether they are named at all. False means the display name is a placeholder, not a person. |
| location | string | Only if they consented to it being shown. |
| duration_sec | integer | Runtime of the spliced episode. |
| published_at | string | When it went public. UTC, ISO 8601. Ordering is on this. |
| slug | string | Stable once published. The URL segment of its page. |
| teaser | string | One line, for a list row. |
| themes | array | Theme slugs. |
| title | string | The episode's headline, as shown on its page. |
| url | string | Absolute, on this space's canonical host — which is the custom domain once one is verified, so build links from this rather than assembling them. |
| limit | integer | How many were returned at most. |
| offset | integer | How many were skipped. |
| total | integer | Matching rows, ignoring limit and offset. |
/v1/episodes/{slug}
One episode, with both transcripts.
| Parameter | Type | In | Meaning |
|---|---|---|---|
| slug | string | path | The episode's slug. |
| Returns | Type | |
|---|---|---|
| answer_transcript | string | What was answered, verbatim. The content asset: this is the text that gets indexed and searched. |
| asker | Asker | Who asked, subject to their consent. |
| display_name | string | What a page would call them — a name if they consented to one being shown, otherwise the space's word for an unnamed asker. |
| is_identified | boolean | Whether they are named at all. False means the display name is a placeholder, not a person. |
| location | string | Only if they consented to it being shown. |
| audio_url | string | The episode audio. A signed URL that expires — fetch it when you need it rather than storing it. |
| duration_sec | integer | Runtime of the spliced episode. |
| image_url | string | Generated key art, 1200x630. Also signed. |
| published_at | string | When it went public. UTC, ISO 8601. Ordering is on this. |
| question_transcript | string | What was asked, verbatim. |
| slug | string | Stable once published. The URL segment of its page. |
| summary | string | The written answer. |
| teaser | string | One line, for a list row. |
| themes | array | Theme slugs. |
| title | string | The episode's headline, as shown on its page. |
| url | string | Absolute, on this space's canonical host — which is the custom domain once one is verified, so build links from this rather than assembling them. |
/v1/episodes/{slug}/publish
Publish an episode, or rehearse it. A real publish emails the asker and every verified follower. `dry_run` runs the same readiness check and counts the same recipients, and sends nothing.
| Parameter | Type | In | Meaning |
|---|---|---|---|
| slug | string | path | The episode's slug. |
| Body | Type | |
|---|---|---|
| dry_run | boolean | Report what publishing would do — readiness and how many people would be emailed — and do none of it. |
| notify | boolean | Send the emails. False publishes silently, which is the right choice for a backfill and the wrong one for a new episode. |
| Returns | Type | |
|---|---|---|
| already_published | boolean | It was already live. Publishing again changes nothing and sends nothing — the original timestamp stands. |
| blocked_by | array | Everything preventing publication, all at once rather than one per attempt. Empty means it is ready. |
| dry_run | boolean | Whether this was a rehearsal. |
| published | boolean | Whether the episode is now live. False on a dry run, and false when something blocks it. |
| url | string | Where it is published, once it is. |
| would_email | integer | How many people a real publish would mail: the asker, plus verified followers, minus the asker if they are both. Zero on a space that may not send mail yet. |
/v1/episodes/{slug}/themes
File an episode under themes. Replaces the episode's themes. Names are matched case-insensitively against existing themes and created when new, so the same theme does not appear twice under different spellings.
| Parameter | Type | In | Meaning |
|---|---|---|---|
| slug | string | path | The episode's slug. |
| Body | Type | |
|---|---|---|
| themes | array | Theme names or slugs, replacing whatever the episode has. Matched case-insensitively against existing themes and created when new, so filing under `retirement` lands on an existing `Retirement` rather than making a second one. Send the whole list, not a delta. |
| Returns | Type | |
|---|---|---|
| themes | array | Theme slugs now on this episode. |
/v1/featured
The featured episodes, in order.
| Returns | Type | |
|---|---|---|
| featured | array | Episode slugs, in the order they appear. First is first. |
/v1/featured
Set the featured list and its order. Replaces the list entirely — send the whole order, not a delta.
| Body | Type | |
|---|---|---|
| featured | array | Episode slugs, in the order wanted. Replaces the list entirely — send the whole order, not a delta. Every slug must be a published episode of this space. |
| Returns | Type | |
|---|---|---|
| featured | array | Episode slugs, in the order they appear. First is first. |
/v1/questions
Questions, newest first. The inbox. Filter to `status=new` for the ones waiting on a human.
| Parameter | Type | In | Meaning |
|---|---|---|---|
| status | string | query | new | answered | published | archived | spam |
| limit | integer | query | Max 100. Default 25. |
| offset | integer | query | Rows to skip. |
| Returns | Type | |
|---|---|---|
| limit | integer | How many were returned at most. |
| offset | integer | How many were skipped. |
| questions | QuestionOut[] | |
| asker | Asker | Who asked, subject to their consent. |
| display_name | string | What a page would call them — a name if they consented to one being shown, otherwise the space's word for an unnamed asker. |
| is_identified | boolean | Whether they are named at all. False means the display name is a placeholder, not a person. |
| location | string | Only if they consented to it being shown. |
| consent_publish_audio | boolean | Whether their voice may be used in an episode. Read-only everywhere: no interface can grant it on somebody's behalf. |
| created_at | string | When it arrived. UTC, ISO 8601. |
| duration_sec | integer | Length of the recording. 0 for a typed question. |
| id | integer | Stable identifier. Use it with /v1/questions/{id}. |
| is_typed | boolean | True when they typed rather than recorded. There is no audio to play and the transcript is what they wrote. |
| status | string | new | answered | published | archived | spam. `new` is the inbox — the ones waiting for a human. |
| transcript | string | What they said or wrote. The thing worth reading to decide whether to answer — triage happens here, not by listening. |
| total | integer | Matching rows, ignoring limit and offset. |
/v1/questions/{question_id}
One question.
| Parameter | Type | In | Meaning |
|---|---|---|---|
| question_id | integer | path | The question's id. |
| Returns | Type | |
|---|---|---|
| asker | Asker | Who asked, subject to their consent. |
| display_name | string | What a page would call them — a name if they consented to one being shown, otherwise the space's word for an unnamed asker. |
| is_identified | boolean | Whether they are named at all. False means the display name is a placeholder, not a person. |
| location | string | Only if they consented to it being shown. |
| consent_publish_audio | boolean | Whether their voice may be used in an episode. Read-only everywhere: no interface can grant it on somebody's behalf. |
| created_at | string | When it arrived. UTC, ISO 8601. |
| duration_sec | integer | Length of the recording. 0 for a typed question. |
| id | integer | Stable identifier. Use it with /v1/questions/{id}. |
| is_typed | boolean | True when they typed rather than recorded. There is no audio to play and the transcript is what they wrote. |
| status | string | new | answered | published | archived | spam. `new` is the inbox — the ones waiting for a human. |
| transcript | string | What they said or wrote. The thing worth reading to decide whether to answer — triage happens here, not by listening. |
/v1/space
The space this key belongs to. The cheapest first call: confirms the credential works and says which hostname to build links from.
| Returns | Type | |
|---|---|---|
| counts | Counts | |
| archived | integer | Set aside deliberately. Not waiting on anyone. |
| awaiting_answer | integer | Questions a human still has to answer. Matches the inbox count in the console. Excludes archived and spam. |
| drafted | integer | Answered but not yet published — nothing public exists yet. |
| published | integer | Answered and published as an episode. |
| spam | integer | Marked spam. Never counts as waiting. |
| hosts | Hosts | |
| base_url | string | https:// + canonical. Build links from this. |
| canonical | string | The hostname public URLs use. Equals platform until a custom domain is verified, then becomes that domain. |
| platform | string | Always-available hostname, <slug>.openmic.dev. Never changes. |
| is_live | boolean | Whether the space is claimed and serving. A space that is not live renders nothing and accepts no questions. |
| name | string | Display name, as shown to visitors. |
| scopes | array | What the calling credential may do, lowest first: read, write, admin. Ranked, so `write` implies `read` and both are listed. |
| slug | string | Unique, URL-safe identifier for this space. |
/v1/themes
Themes, in the space's own order.
| Parameter | Type | In | Meaning |
|---|---|---|---|
| limit | integer | query | Max 100. Default 25. |
| offset | integer | query | Rows to skip. |
| Returns | Type | |
|---|---|---|
| limit | integer | How many were returned at most. |
| offset | integer | How many were skipped. |
| themes | ThemeOut[] | |
| description | string | The space's own one-line description, if it set one. |
| name | string | What the space calls this theme. |
| published_count | integer | Published episodes carrying this theme. |
| slug | string | Stable identifier. Filter episodes with it. |
| total | integer | Matching rows, ignoring limit and offset. |