openmic

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.

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

GET

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

ReturnsType
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.
PATCH

/v1/branding

Change appearance. Only what is sent changes. Colours are validated on the way in, not at render.

BodyType
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.
ReturnsType
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.
GET

/v1/episodes

Published episodes, newest first. Drafts are not episodes: they have no public URL.

ParameterTypeInMeaning
theme string query Theme slug.
limit integer query Max 100. Default 25.
offset integer query Rows to skip.
ReturnsType
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.
GET

/v1/episodes/{slug}

One episode, with both transcripts.

ParameterTypeInMeaning
slug string path The episode's slug.
ReturnsType
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.
POST

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

ParameterTypeInMeaning
slug string path The episode's slug.
BodyType
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.
ReturnsType
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.
PATCH

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

ParameterTypeInMeaning
slug string path The episode's slug.
BodyType
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.
ReturnsType
themes array Theme slugs now on this episode.
GET

/v1/featured

The featured episodes, in order.

ReturnsType
featured array Episode slugs, in the order they appear. First is first.
PATCH

/v1/featured

Set the featured list and its order. Replaces the list entirely — send the whole order, not a delta.

BodyType
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.
ReturnsType
featured array Episode slugs, in the order they appear. First is first.
GET

/v1/questions

Questions, newest first. The inbox. Filter to `status=new` for the ones waiting on a human.

ParameterTypeInMeaning
status string query new | answered | published | archived | spam
limit integer query Max 100. Default 25.
offset integer query Rows to skip.
ReturnsType
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.
GET

/v1/questions/{question_id}

One question.

ParameterTypeInMeaning
question_id integer path The question's id.
ReturnsType
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.
GET

/v1/space

The space this key belongs to. The cheapest first call: confirms the credential works and says which hostname to build links from.

ReturnsType
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.
GET

/v1/themes

Themes, in the space's own order.

ParameterTypeInMeaning
limit integer query Max 100. Default 25.
offset integer query Rows to skip.
ReturnsType
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.