Concepts
Authentication, the response envelope, workspaces, money, realtime, generations, and the REST API.
Authentication
Ghostfeed is both the authorization server and the protected resource. Any
OAuth-capable MCP client discovers everything from a 401 response; you never
copy keys around.
- An unauthenticated request returns
401with aWWW-Authenticateheader pointing at the discovery documents (/.well-known/oauth-authorization-server). - The client registers itself (Dynamic Client Registration), then opens the browser: Google sign-in, then Ghostfeed's consent screen.
- On consent you choose the workspace grant (all or specific) and one default workspace for read-only calls.
- The client exchanges the authorization code (PKCE S256) for tokens.
| Token | Prefix | Lifetime | Purpose |
|---|---|---|---|
| Access | gf_at_ | 1 hour | Sent as Authorization: Bearer gf_at_... on every call |
| Refresh | gf_rt_ | 60 days | Exchanged for new access tokens; single use (rotation) |
Tokens are opaque. The server stores only HMAC fingerprints, so a leaked database contains nothing usable. Refresh tokens rotate on every use. Reusing a consumed refresh token revokes the whole grant (theft detection).
Auth failures use the envelope below with code: "unauthorized" and a
remediation string: an expired access token says to refresh, a revoked
credential says to re-authenticate.
The response envelope
Every /api/v2 response and every MCP tool result is one of two shapes:
{ "success": true, "requestId": "...", "data": {} }{
"success": false,
"requestId": "...",
"error": {
"code": "insufficient_credits",
"message": "Not enough credits.",
"status": 402,
"retryable": false,
"remediation": "Top up credits and retry. Check the balance with get_credits."
}
}code is a small closed set, projected straight from the server's own
definitions:
| code | HTTP | retryable | meaning |
|---|---|---|---|
validation_failed | 400 | no | Input failed validation; fieldErrors lists the problems per field. |
unauthorized | 401 | no | Missing, expired, or revoked credential. |
forbidden | 403 | no | Valid credential, but no access to this workspace or resource. |
not_found | 404 | no | No such resource in this workspace. |
insufficient_credits | 402 | no | Credit balance is too low for this operation. |
approval_required | 402 | yes | Reserved for operations that will require explicit human approval (none today). |
idempotency_conflict | 409 | no | This idempotency key was already used with a different request. |
rate_limited | 429 | yes | Too many requests; back off and retry. |
provider_error | 502 | yes | An upstream provider failed; charges for failed work are refunded. |
internal_error | 500 | yes | Unexpected server error; details are logged server-side, never leaked. |
Unexpected server errors are logged internally and returned as
internal_error; internal details never leak into a response. remediation
tells an agent what to do next. requestId is the handle for support.
Workspaces
A workspace is the content boundary. The credential stores two separate facts:
the grant (the workspaces it may reach) and one immutable default used
when a read omits its target. A read can explicitly override the default with
the per-call workspace parameter or X-Workspace-Id, but only inside the
grant. Every workspace-scoped write must name its target workspace on the
call itself; writes never inherit the default. Dashboard navigation is not part
of this decision.
Both doors enforce this the same way, so an agent learns one habit. MCP takes
workspace as a required tool parameter. REST takes it in the JSON body, or as
a ?workspace= query param on a bodyless call like a DELETE. The
connection-wide X-Workspace-Id header is a read convenience only — it is not
accepted for writes, because a stale connection header is exactly how a
multi-workspace agent would write into the wrong workspace. Name the target on
the call.
Agents discover stable ids and slugs with list_workspaces. Names are not
unique; when two workspaces share one, the error lists the matching ids so the
agent can retry unambiguously.
A credential granted specific workspaces can never reach outside them, and live access is re-checked on every request: leaving a team closes access immediately, valid token or not. Two agents in different workspaces never see each other's content.
Money
Paid operations charge credits when the work starts and refund automatically
on failure, including partial refunds when a provider delivers fewer images
than requested. Every paid result carries creditsSpent and
creditsRemaining. Free operations (approve, rename, list) never touch the
balance.
Retries are safe: pass an idempotencyKey and a repeated call reuses the
original charge instead of billing twice.
Realtime
Mutations emit workspace-scoped events the dashboard listens to over SSE. An
avatar created by an agent appears in an open dashboard in realtime, no
refresh. Events are invalidation hints only ({workspaceId, topic}); no
content rides the stream.
Generations (async work)
Long-running work (video, upcoming verticals) returns a gen_... id
immediately. Poll get_generation until the state is terminal:
queued → running → succeeded | failed, plus canceled and needs_action.
Avatars generate synchronously and return their drafts directly, no polling.
This normalized vocabulary is the one contract every vertical answers in, even
when the underlying record uses its own words. A slideshow variant, for
instance, stores pending | processing | ready | failed; the generation ledger
maps that to queued | running | succeeded | failed so a single polling loop
works across product types. The mapping is enforced in code and pinned by a
mirror test, so get_slideshow's per-variant status (ready) and
get_generation's normalized state (succeeded) describe the same deck by
design, never by coincidence.
REST
Every tool has an HTTP twin under /api/v2, same bearer token, same envelope:
curl https://api.ghostfeed.ai/api/v2/me \
-H "Authorization: Bearer gf_at_..."Each entry in the MCP docs shows the twin's exact HTTP method and path. Both adapters call the same operation, so the two doors are never allowed to drift: they share one input schema, one response envelope, and one set of error codes. A canonical manifest and a bidirectional CI test reject an MCP-only tool, a REST-only endpoint, or a method/path mismatch, and the same mirror tests that pin credit costs and layout constants pin the envelope shape. Pick the door your runtime prefers; the contract is identical on both.
For REST, ids shown as :id, :slideshowId, or :slideIndex belong in the
URL. Remaining read inputs use query parameters; POST, PUT, and PATCH inputs
use JSON. POST /pinterest/search is a read-style search whose array input is
JSON. A workspace-scoped write names workspace in the JSON body, or as a
?workspace= query param on a bodyless DELETE (the X-Workspace-Id header is
for reads only, see Workspaces above); reads may use workspace in the query or
the header.