oi.

oi documentation

Everything an agent — or the person directing one — needs to publish to oi, control who can see it, and keep it up to date.

Reading this as software?
  • The same content as markdown: /docs.md.
  • Condensed for a context window: /llms.txt and /llms-full.txt.
  • Machine description of every endpoint: /openapi.json.
  • Install the operating manual: npx skills add oimrdk/skill --skill oi -g
Every endpoint in the OpenAPI description (42 of 90 available here)

Generated from openapi.json, the same document an agent fetches, so this table and the machine-readable spec are always the same list — including the availability column, which is read back out of that document's x-oi-availability markers. See Availability for what each value means.

MethodPathSummaryOn this deployment
POST/api/auth/agent/request-codeRequest an email sign-in codeAvailable
POST/api/auth/agent/verify-codeVerify an email code and receive an API keyAvailable
POST/api/v1/publishCreate a SiteAvailable
GET/api/v1/publishesList account SitesAvailable
GET/api/v1/analyticsGet account analyticsNot implemented
GET/api/v1/publishes/searchSearch account SitesNot implemented
GET/api/v1/publishes/{slug}/analyticsGet Site analyticsNot implemented
GET/api/v1/publishes/{slug}/data/{collection}List Site Data recordsAvailable
POST/api/v1/publishes/{slug}/data/{collection}Create a Site Data recordAvailable
GET/api/v1/publishes/{slug}/data/{collection}/{recordId}Get a Site Data recordAvailable
PATCH/api/v1/publishes/{slug}/data/{collection}/{recordId}Patch a Site Data recordAvailable
DELETE/api/v1/publishes/{slug}/data/{collection}/{recordId}Delete a Site Data recordAvailable
GET/api/v1/profileGet profile settingsNot implemented
PATCH/api/v1/profileUpdate profile settingsNot implemented
PATCH/api/v1/profile/usernameChange profile usernameNot implemented
GET/api/v1/profile/sitesList Sites on profileNot implemented
POST/api/v1/profile/sitesAdd Site to profileNot implemented
DELETE/api/v1/profile/sites/{slug}Remove Site from profileNot implemented
GET/api/v1/publish/{slug}Get Site detailsAvailable
PUT/api/v1/publish/{slug}Update an existing SiteAvailable
DELETE/api/v1/publish/{slug}Delete a SiteAvailable
POST/api/v1/publish/{slug}/duplicateDuplicate a SiteAvailable
POST/api/v1/publish/{slug}/finalizeFinalize a pending Site versionAvailable
GET/api/v1/publish/{slug}/versionsList a Site's published versionsAvailable
DELETE/api/v1/publish/{slug}/versions/{versionId}Permanently delete a recorded versionAvailable
POST/api/v1/publish/{slug}/versions/{versionId}/restoreRestore a recorded version as the live SiteAvailable
POST/api/v1/publish/{slug}/claimClaim an anonymous SiteAvailable
POST/api/v1/publish/{slug}/uploads/refreshRefresh upload URLs for a pending Site versionAvailable
PATCH/api/v1/publish/{slug}/metadataPatch Site metadata and access controlsAvailable
GET/api/v1/publish/{slug}/accessRead the Site access policyAvailable
PATCH/api/v1/publish/{slug}/accessUpdate Site access mode and allowlistsAvailable
POST/api/v1/publish/{slug}/access/invitesSend invite emails for an existing access policyAvailable
POST/api/v1/publish/from-drivePublish a Drive version as a SiteAvailable
GET/api/v1/drivesList account DrivesAvailable
POST/api/v1/drivesCreate a DriveAvailable
GET/api/v1/drives/defaultGet or create the default DriveAvailable
GET/api/v1/drives/{driveId}Get Drive detailsAvailable
PATCH/api/v1/drives/{driveId}Patch Drive metadataAvailable
DELETE/api/v1/drives/{driveId}Soft-delete a DriveAvailable
GET/api/v1/drives/{driveId}/filesList Drive filesAvailable
PATCH/api/v1/drives/{driveId}/filesApply a batch of Drive file operationsAvailable
GET/api/v1/drives/{driveId}/files/{path}Read a Drive fileAvailable
DELETE/api/v1/drives/{driveId}/files/{path}Delete a Drive file or prefixAvailable
POST/api/v1/drives/{driveId}/files/uploadsStage a Drive file writeAvailable
POST/api/v1/drives/{driveId}/files/finalizeFinalize a staged Drive uploadAvailable
POST/api/v1/drives/{driveId}/files/moveMove a Drive fileAvailable
GET/api/v1/drives/{driveId}/tokensList Drive tokensAvailable
POST/api/v1/drives/{driveId}/tokensCreate a scoped Drive tokenAvailable
GET/api/v1/domainsList custom domainsUnavailable here
POST/api/v1/domainsAdd a custom domainUnavailable here
GET/api/v1/domains/{domain}Get custom domain statusUnavailable here
DELETE/api/v1/domains/{domain}Remove a custom domainUnavailable here
GET/api/v1/handleGet account subdomain handleUnavailable here
POST/api/v1/handleCreate account subdomain handle (discontinued)Unavailable here
DELETE/api/v1/handleDelete account subdomain handleUnavailable here
GET/api/v1/linksList subdomain handle or domain linksUnavailable here
POST/api/v1/linksCreate a link from a subdomain handle/domain path to a SiteUnavailable here
GET/api/v1/links/{location}Get a linkUnavailable here
PATCH/api/v1/links/{location}Update a linkUnavailable here
DELETE/api/v1/links/{location}Delete a linkUnavailable here
GET/api/v1/accountsList accounts available to the callerNot implemented
POST/api/v1/accountsCreate a workspaceNot implemented
GET/api/v1/accounts/subdomain-availabilityCheck workspace subdomain availabilityNot implemented
POST/api/v1/accounts/{accountId}/provisioningProvision a workspace subdomainNot implemented
GET/api/v1/accounts/{accountId}/site-labelsList workspace Site labelsNot implemented
POST/api/v1/accounts/{accountId}/site-labelsAssign a workspace Site labelNot implemented
PATCH/api/v1/accounts/{accountId}/site-labels/{label}Rename a workspace Site labelNot implemented
PATCH/api/v1/accounts/{accountId}Rename a workspace or set its iconNot implemented
DELETE/api/v1/accounts/{accountId}Delete a workspaceNot implemented
GET/api/v1/accounts/{accountId}/membersList workspace membersNot implemented
POST/api/v1/accounts/{accountId}/membersAdd a workspace member directlyNot implemented
PATCH/api/v1/accounts/{accountId}/members/{userId}Change a member's roleNot implemented
DELETE/api/v1/accounts/{accountId}/members/{userId}Remove a member or leave the workspaceNot implemented
GET/api/v1/accounts/{accountId}/invitesList pending workspace invitesNot implemented
POST/api/v1/accounts/{accountId}/invitesSend a workspace inviteNot implemented
DELETE/api/v1/accounts/{accountId}/invites/{inviteId}Revoke a workspace inviteNot implemented
POST/api/v1/accounts/{accountId}/invites/{inviteId}/acceptAccept a workspace inviteNot implemented
POST/api/v1/accounts/{accountId}/invites/{inviteId}/declineDecline a workspace inviteNot implemented
GET/api/v1/me/invitesList the caller's pending workspace invitesNot implemented
GET/api/v1/accounts/{accountId}/domain-rulesList auto-join domain rulesNot implemented
POST/api/v1/accounts/{accountId}/domain-rulesCreate an auto-join domain ruleNot implemented
DELETE/api/v1/accounts/{accountId}/domain-rules/{ruleId}Disable an auto-join domain ruleNot implemented
POST/api/v1/accounts/{accountId}/domain-rules/applyJoin a workspace via a matching domain ruleNot implemented
GET/api/v1/me/variablesList service variablesNot implemented
PUT/api/v1/me/variables/{name}Create or update a service variableNot implemented
DELETE/api/v1/me/variables/{name}Delete a service variableNot implemented
GET/api/v1/me/keysList API keysAvailable
POST/api/v1/me/keysCreate a named API keyAvailable
DELETE/api/v1/me/keys/{id}Revoke an API keyAvailable
POST/api/v1/supportSend an authenticated support requestNot implemented

Overview#

oi lets agents publish websites, apps, and files to live URLs in seconds.

  • Every site gets a live URL at <slug>.oi.mrdk.io.
  • No account is needed for anonymous sites (they expire after 24 hours).
  • An API key makes sites permanent and unlocks version history and access control.
  • The contract this API implements also describes custom domains, Site Data, Drives, analytics, profiles, and Workspaces at {label}.{workspace}.oi.mrdk.io. Most of that is not implemented on this deployment — read Availability before planning against anything below.

Read the availability section first. This documentation covers the whole oi.mrdk.io API contract, because that is the shape this deployment targets and because an agent comparing the two should see the same surface. It is not a list of what answers today. Availability says exactly which operations run here, which are merely unbuilt, and which cannot run here at all, and every endpoint line in this document that is not answered here is marked. /status reports what the running process can verify at the moment you load it.

Availability#

This deployment implements 42 of the 90 operations in the oi API contract. 36 are defined by the contract but not built here, and 12 cannot run on this deployment at all.

Every operation in /openapi.json carries an x-oi-availability marker — available, planned, or unavailable — plus x-oi-available (boolean) and x-oi-note. Read it before planning a call. The contract is served whole on purpose: it is the shape this deployment targets, and an agent comparing this API against the reference should see the same surface with the truth about availability layered on, not a shorter list.

AreaOperationsOn this deployment
Auth5All available
Sites1817 of 18 available
Analytics2None — not implemented here
Site Data5All available
Profiles6None — not implemented here
Drives15All available
Domains12None — unavailable on this deployment
Workspaces23None — not implemented here
Variables3None — not implemented here
Support1None — not implemented here

What runs here (42 operations), by contract area:

  • Auth: POST /api/auth/agent/request-code, POST /api/auth/agent/verify-code, GET /api/v1/me/keys, POST /api/v1/me/keys, DELETE /api/v1/me/keys/{id}
  • Sites: POST /api/v1/publish, GET /api/v1/publishes, GET /api/v1/publish/{slug}, PUT /api/v1/publish/{slug}, DELETE /api/v1/publish/{slug}, POST /api/v1/publish/{slug}/duplicate, POST /api/v1/publish/{slug}/finalize, GET /api/v1/publish/{slug}/versions, DELETE /api/v1/publish/{slug}/versions/{versionId}, POST /api/v1/publish/{slug}/versions/{versionId}/restore, POST /api/v1/publish/{slug}/claim, POST /api/v1/publish/{slug}/uploads/refresh, PATCH /api/v1/publish/{slug}/metadata, GET /api/v1/publish/{slug}/access, PATCH /api/v1/publish/{slug}/access, POST /api/v1/publish/{slug}/access/invites, POST /api/v1/publish/from-drive
  • Site Data: GET /api/v1/publishes/{slug}/data/{collection}, POST /api/v1/publishes/{slug}/data/{collection}, GET /api/v1/publishes/{slug}/data/{collection}/{recordId}, PATCH /api/v1/publishes/{slug}/data/{collection}/{recordId}, DELETE /api/v1/publishes/{slug}/data/{collection}/{recordId}
  • Drives: GET /api/v1/drives, POST /api/v1/drives, GET /api/v1/drives/default, GET /api/v1/drives/{driveId}, PATCH /api/v1/drives/{driveId}, DELETE /api/v1/drives/{driveId}, GET /api/v1/drives/{driveId}/files, PATCH /api/v1/drives/{driveId}/files, GET /api/v1/drives/{driveId}/files/{path}, DELETE /api/v1/drives/{driveId}/files/{path}, POST /api/v1/drives/{driveId}/files/uploads, POST /api/v1/drives/{driveId}/files/finalize, POST /api/v1/drives/{driveId}/files/move, GET /api/v1/drives/{driveId}/tokens, POST /api/v1/drives/{driveId}/tokens

Not implemented here (36). Defined by the contract, no route behind them. Nothing structural blocks them; they are simply not built:

  • Sites (1): searchSites
  • Analytics (2): getAccountAnalytics, getSiteAnalytics
  • Profiles (6): getProfile, patchProfile, patchProfileUsername, listProfileSites, addProfileSite, removeProfileSite
  • Domains (12): listDomains, createDomain, getDomain, deleteDomain, getHandle, createHandle, deleteHandle, listLinks, createLink, getLink, updateLink, deleteLink
  • Workspaces (23): listAccounts, createWorkspace, checkWorkspaceSubdomainAvailability, provisionWorkspaceSubdomain, listWorkspaceSiteLabels, assignWorkspaceSiteLabel, renameWorkspaceSiteLabel, updateWorkspace, deleteWorkspace, listWorkspaceMembers, addWorkspaceMember, updateWorkspaceMemberRole, removeWorkspaceMember, listWorkspaceInvites, createWorkspaceInvite, revokeWorkspaceInvite, acceptWorkspaceInvite, declineWorkspaceInvite, listMyWorkspaceInvites, listWorkspaceDomainRules, createWorkspaceDomainRule, disableWorkspaceDomainRule, applyWorkspaceDomainRule
  • Variables (3): listVariables, setVariable, deleteVariable
  • Support (1): createSupportRequest

12 of those are not merely unbuilt — they cannot run here. Custom domains, subdomain handles, and links all need a TLS certificate for a hostname minted after the fact. The proxy in front of this deployment does HTTP-01 ACME only, which cannot issue a wildcard, and per-hostname issuance would burn a certificate rate limit shared with unrelated services. Treat those as a closed door, not a roadmap item.

Calling one of them returns HTTP 404 with code not_found, an error line naming the operation, and details.reason = operation_not_available_on_deployment. That is deliberately distinguishable from a mistyped path, which still returns "error": "API route not found". Neither is worth retrying: on the first, tell the user the capability is missing instead of working around it silently.

"Available" is not "identical". An operation can answer and still handle a field differently from the contract, or require something the contract's schema does not express. Those differences are listed per operation below and carried in the spec as x-oi-divergences on the operation itself, so an agent reading one operation meets them without reading this page. Everything not listed behaves as the contract describes.

OperationWhereKindWhat happens here
createSitefield ttlSecondsconditionalHonored only on an authenticated create. An anonymous create ignores it and expires 24 hours after creation regardless.
createSiteresponse expiresAtomittedAbsent from an authenticated create response even when ttlSeconds set an expiry; the expiry is applied. Read it back from GET /api/v1/publish/{slug}.
createSitefield accountrejectedThere are no workspaces here. A selector naming any account other than the caller's personal one answers 404 account_not_found instead of publishing there.
listSitesresponse ownership / sharedSource / workspaceignoredPresent under scope=all to keep the response shape, and constant here: ownership is always "owned", sharedSource always null, workspace always null. There is no sharing and no workspace on this deployment, so branching on them is dead code. primaryUrl always equals siteUrl for the same reason.
listSiteDataRecordsprecondition collection declared in .oi/data.jsonrequiredThe collection must be declared in the Site's published manifest: an undeclared collection is 404, and a manifest that does not compile makes every record operation 409 until it is fixed. See /docs#sitedata-manifest.
listSiteDataRecordsprecondition Site is account-ownedrequiredAn anonymous Site stores no records however good its manifest is, and the two tiers refuse differently: the Site's own in-page endpoints answer 403 account_required, while this owner API answers 404 for that slug, because an unclaimed Site is invisible on the account surfaces. Claim it with POST /api/v1/publish/{slug}/claim first.
createSiteDataRecordprecondition collection declared in .oi/data.jsonrequiredThe collection must be declared in the Site's published manifest: an undeclared collection is 404, and a manifest that does not compile makes every record operation 409 until it is fixed. See /docs#sitedata-manifest.
createSiteDataRecordprecondition Site is account-ownedrequiredAn anonymous Site stores no records however good its manifest is, and the two tiers refuse differently: the Site's own in-page endpoints answer 403 account_required, while this owner API answers 404 for that slug, because an unclaimed Site is invisible on the account surfaces. Claim it with POST /api/v1/publish/{slug}/claim first.
getSiteDataRecordprecondition collection declared in .oi/data.jsonrequiredThe collection must be declared in the Site's published manifest: an undeclared collection is 404, and a manifest that does not compile makes every record operation 409 until it is fixed. See /docs#sitedata-manifest.
getSiteDataRecordprecondition Site is account-ownedrequiredAn anonymous Site stores no records however good its manifest is, and the two tiers refuse differently: the Site's own in-page endpoints answer 403 account_required, while this owner API answers 404 for that slug, because an unclaimed Site is invisible on the account surfaces. Claim it with POST /api/v1/publish/{slug}/claim first.
patchSiteDataRecordprecondition collection declared in .oi/data.jsonrequiredThe collection must be declared in the Site's published manifest: an undeclared collection is 404, and a manifest that does not compile makes every record operation 409 until it is fixed. See /docs#sitedata-manifest.
patchSiteDataRecordprecondition Site is account-ownedrequiredAn anonymous Site stores no records however good its manifest is, and the two tiers refuse differently: the Site's own in-page endpoints answer 403 account_required, while this owner API answers 404 for that slug, because an unclaimed Site is invisible on the account surfaces. Claim it with POST /api/v1/publish/{slug}/claim first.
deleteSiteDataRecordprecondition collection declared in .oi/data.jsonrequiredThe collection must be declared in the Site's published manifest: an undeclared collection is 404, and a manifest that does not compile makes every record operation 409 until it is fixed. See /docs#sitedata-manifest.
deleteSiteDataRecordprecondition Site is account-ownedrequiredAn anonymous Site stores no records however good its manifest is, and the two tiers refuse differently: the Site's own in-page endpoints answer 403 account_required, while this owner API answers 404 for that slug, because an unclaimed Site is invisible on the account surfaces. Claim it with POST /api/v1/publish/{slug}/claim first.
updateSitefield claimTokenrequiredThe schema marks it optional, but an unclaimed anonymous Site cannot be updated without it: no Authorization and no claimToken answers 401. An account-owned Site ignores it and takes the bearer key instead.
updateSitefield ttlSecondsconditionalSame rule as create: honored on an authenticated update, ignored on an anonymous one. An update never extends an anonymous Site's expiry either way.
updateSitefield accountrejectedThere are no workspaces here; a selector naming any other account answers 404 account_not_found.
updateSiteresponse expiresAt / anonymous / warningomittedAn anonymous CREATE returns these; an anonymous UPDATE does not, so an update response is not where you learn when the Site dies. Keep the expiry from the create response — the update does not change it. claimToken and claimUrl are withheld on purpose: they are issued exactly once.
duplicateSiteresponse thumbnailJobIdignoredReturned as a fresh UUID to keep the response shape; no thumbnail is queued, rendered, or served here. It identifies nothing.
duplicateSitefield viewerignoredOnly title, description and ogImagePath are read, each falling back to the source's value when absent. The contract types viewer as a free-form object; any other key is accepted and ignored.
duplicateSiteprecondition source has a live versionrequiredA Site with only a pending version answers 409; finalize it before duplicating.
duplicateSiteprecondition access mode of the copyderivedDuplicating a GATED Site does not produce an ungated copy here: the copy starts restricted to the caller's own address. Pass {"access":"anyone_with_link"} to opt out; the response's access block always states what the copy became.
finalizeSiteVersionresponse addedToProfileignoredAlways false, because public profiles are not implemented here — not a signal that this particular Site was excluded. Do not tell the user their Site was kept off a profile.
finalizeSiteVersionresponse thumbnailJobIdignoredReturned as a fresh UUID to keep the response shape, but no thumbnail is queued, rendered, or served here (/api/thumbnails/ answers 404). It identifies nothing: do not poll it or surface it.
finalizeSiteVersionfield workspaceLabelignoredAccepted and ignored: label URLs belong to workspaces, which do not exist here. The response carries no workspaceLabel and no accountUrl, and no label is reserved.
listSiteVersionsresponse previewUrl / thumbnailUrlomittedNever present on a version row: per-version preview hosts and thumbnails are not served here. Link the Site's own URL after a restore instead of a per-version preview.
refreshSiteUploadUrlsprecondition authorizationderivedThe contract declares bearerAuth only; this deployment also accepts an unclaimed anonymous Site's claimToken (in the body or as ?claimToken=), because that flow otherwise has no way to re-mint its own upload URLs.
patchSiteAccessfield mode=account_membersrejectedAnswers 409 workspace_access_mode_unsupported: the mode applies to workspace-owned Sites and there are none here. Use anyone_with_link or restricted.
publishFromDrivefield slugconditionalNames an EXISTING Site this account owns, to publish a new live version into. It does not reserve a name: an unknown or unowned slug answers 404 rather than creating that Site. Omit it to mint a new Site, live immediately with no finalize step.
publishFromDrivefield viewerrejectedOnly title, description and ogImagePath are accepted; any other key answers 400. Note the asymmetry with duplicateSite, which accepts and silently ignores extra viewer keys.
applyDriveFileBatchprecondition baseVersionId or per-op ETagsrequiredThe schema marks neither required; this deployment refuses a batch with neither (400). A multi-path rewrite has no single path whose state could be pinned for it, so the precondition has to be stated.
readDriveFileheader RangeignoredDrive reads are whole-object: a Range request answers 200 with the entire file and no Accept-Ranges or Content-Range, so a client that assumes 206 will silently concatenate whole copies. The Site-serving tier does honor Range; this one does not.
deleteDriveFileprecondition If-Match (single file)derivedOptional, as the contract declares. A single-file delete with no If-Match and no baseVersionId pins the file as the request found it and commits against that; a path matching nothing is 404, not a silent no-op. A declared If-Match still wins and a stale one still answers 409.
deleteDriveFileprecondition recursive + baseVersionId (folder)requiredDeleting a folder needs ?recursive=true AND ?baseVersionId= (read headVersionId from GET /api/v1/drives/{driveId}). Same rule as the batch endpoint: a multi-path delete has no single path whose state could stand for it. A trailing slash on the path is rejected as a malformed Drive path.
createDriveFileUploadprecondition ifMatch / ifNoneMatchderivedBoth stay optional as the contract declares. When neither is sent, staging records the path's current ETag (or its absence) and the response reports it as precondition; finalize then commits against exactly that state.
finalizeDriveFileUploadprecondition staged preconditionderivedThe contract's minimal body {uploadId} is sufficient. The commit is checked against the state recorded at staging, so a concurrent writer produces 409 rather than a silent overwrite.
createDriveTokenfield perms vs permissionsrejectedBoth spellings are accepted, but sending both with different values answers 400 rather than picking one. Send one.

37 differences across 20 operations. kind reads: ignored (accepted on the way in, or returned on the way out, and means nothing), rejected (the call fails when used), omitted (a response field that is absent here), conditional (honored only as stated), derived (the server supplies what the contract leaves to you), required (this deployment asks for more than the schema does).

In endpoint lists below, a line with no marker is answered here; [not implemented here] and [unavailable here] mean what they say.

Install oi#

Install the oi skill so your agent can publish on its own:

text

npx skills add oimrdk/skill --skill oi -g

For repo-pinned/project-local installs, run the Skills command without -g. If that fails, use the fallback installer:

text

curl -fsSL https://oi.mrdk.io/install.sh | bash

After installation an agent publishes with a single script call. The skill repo has the details.

Quick start#

A site goes live in three steps, no account required:

  1. Create the site:
text

curl -sS https://oi.mrdk.io/api/v1/publish \
  -H "X-Oi-Client: cursor/direct-api" \
  -H "content-type: application/json" \
  -d '{
    "files": [
      { "path": "index.html", "size": 1234, "contentType": "text/html; charset=utf-8" }
    ]
  }'
  1. Upload each file:
text

curl -X PUT "<upload.uploads[0].url>" \
  -H "Content-Type: text/html; charset=utf-8" \
  --data-binary @index.html
  1. Finalize:
text

curl -sS -X POST "<finalizeUrl>" \
  -H "content-type: application/json" \
  -d '{ "versionId": "<versionId>" }'

The create response carries the live siteUrl, presigned upload URLs, and a finalizeUrl. Anonymous sites expire after 24 hours; only anonymous responses include a claimUrl — pass it on to the user so they can keep the site.

Authentication#

Two modes:

  • Authenticated: send Authorization: Bearer <API_KEY>. Keys come from agent code verification or from the dashboard after web sign-in.
  • Anonymous: send no Authorization header. Sites expire in 24 hours and lower limits apply.

Optional attribution: send X-Oi-Client: <agent>/<tool> (example: cursor/publish-sh) so oi can track reliability per client. Missing or invalid values are ignored.

Getting an API key#

Two paths to a key:

Option A: agent-assisted sign-up. The whole flow fits inside the agent. Ask for the user's email and request a one-time code:

text

curl -sS https://oi.mrdk.io/api/auth/agent/request-code \
  -H "content-type: application/json" \
  -d '{"email": "user@example.com"}'

The user pastes the emailed code back into the agent; verify it to receive the key:

text

curl -sS https://oi.mrdk.io/api/auth/agent/verify-code \
  -H "content-type: application/json" \
  -d '{"email":"user@example.com","code":"ABCD-2345"}'

New email addresses get an account created automatically.

Option B: dashboard sign-up. Sign in at https://oi.mrdk.io/dashboard and copy the API key from the dashboard.

Storing the API key#

Whichever way the key was obtained, write it to the credentials file:

text

mkdir -p ~/.oi && echo "<API_KEY>" > ~/.oi/credentials && chmod 600 ~/.oi/credentials

The publish script resolves the key from these sources (first match wins):

  1. --api-key flag (CI/scripting only — avoid in interactive use)
  2. $OI_API_KEY environment variable
  3. ~/.oi/credentials file (recommended)

API keys#

An account holds up to 50 named API keys, each revocable independently. Give every agent or tool its own key (e.g. claude, cursor) so revoking one credential leaves the rest working. Named keys start with oik_ and are valid anywhere Authorization: Bearer <API_KEY> is accepted. The original sign-up key shows up in the key list with an empty name and the id legacy.

Keys can be managed from the dashboard or over the API.

Create a key#

POST /api/v1/me/keys

text

curl -sS https://oi.mrdk.io/api/v1/me/keys \
  -H "Authorization: Bearer <API_KEY>" \
  -H "content-type: application/json" \
  -d '{"name": "claude"}'

Response:

text

{
  "id": "9f4e...",
  "name": "claude",
  "key": "oik_<64 hex chars>",
  "keySuffix": "a1b2",
  "createdAt": "2026-06-11T00:00:00.000Z"
}

List keys#

GET /api/v1/me/keys

text

curl -sS https://oi.mrdk.io/api/v1/me/keys \
  -H "Authorization: Bearer <API_KEY>"

Returns every active key with its full value (id, name, key, keySuffix, createdAt, lastUsedAt, current), so a key can be re-read later. current marks the key that authenticated the request — an agent that has lost track of which credential it is holding can find itself in the list without minting a new one.

Revoke a key#

DELETE /api/v1/me/keys/:id

text

curl -sS -X DELETE https://oi.mrdk.io/api/v1/me/keys/<KEY_ID> \
  -H "Authorization: Bearer <API_KEY>"

<KEY_ID> is the id from the key list, not the key value. Revocation takes effect immediately and touches only that key. DELETE /api/v1/me/keys/legacy revokes the original sign-up key.

API keys belong to you, not to one account: the same key targets your personal account by default and any workspace you belong to when the request carries an account selector — see Workspaces → Account selector.

Create a site#

POST /api/v1/publish (alias: POST /api/v1/artifact)

Request body:

text

{
  "files": [
    { "path": "index.html", "size": 1234, "contentType": "text/html; charset=utf-8", "hash": "a1b2c3d4..." },
    { "path": "assets/app.js", "size": 999, "contentType": "text/javascript; charset=utf-8", "hash": "e5f6a7b8..." }
  ],
  "ttlSeconds": null,
  "displayName": "My site",
  "displayDescription": "A short summary shown in your dashboard and search results.",
  "viewer": {
    "title": "My site",
    "description": "Published by an agent",
    "ogImagePath": "assets/cover.png"
  }
}
  • files (required): array of { path, size, contentType, hash }. Paths are relative to the site root (index.html, assets/style.css) — never prefixed with a parent directory like my-project/index.html.
  • hash (optional): SHA-256 hex digest, 64 lowercase chars. On update, files whose hash matches the previous version are dropped from upload.uploads[] and listed in upload.skipped[]; the server copies them at finalize.
  • ttlSeconds (optional): expiry in seconds. Ignored for anonymous sites.
  • displayName (optional, max 80 chars): owner-facing Site title shown in the dashboard, search, profile cards, and API responses.
  • displayDescription (optional, max 280 chars): owner-facing Site summary for the dashboard, search, and API responses.
  • viewer (optional): metadata for auto-viewer pages (applies only when the site has no index.html).

Response (authenticated):

text

{
  "slug": "quiet-meadow-b3t7",
  "siteUrl": "https://quiet-meadow-b3t7.oi.mrdk.io/",
  "status": "pending",
  "isLive": false,
  "requiresFinalize": true,
  "displayName": "My site",
  "displayDescription": "A short summary shown in your dashboard and search results.",
  "upload": {
    "versionId": "01K...",
    "uploads": [
      {
        "path": "index.html",
        "method": "PUT",
        "url": "https://<presigned-url>",
        "headers": { "Content-Type": "text/html; charset=utf-8" }
      }
    ],
    "skipped": ["assets/app.js"],
    "finalizeUrl": "https://oi.mrdk.io/api/v1/publish/quiet-meadow-b3t7/finalize",
    "expiresInSeconds": 3600
  },
  "derived": {
    "rootFileCount": 2,
    "rootSingleFilePath": null,
    "hasIndexHtml": true,
    "prefix": "sites/quiet-meadow-b3t7/v/01K.../"
  }
}

derived reports the manifest as the server read it, before a single byte is uploaded: hasIndexHtml and rootFileCount are what decide which of the serving rules the finished Site will hit, rootSingleFilePath is set only when the whole Site is one root file (the auto-viewer case), and prefix is the storage key prefix this version's uploads land under. An agent that wanted an index.html and sees "hasIndexHtml": false can fix the manifest before uploading rather than after.

Anonymous responses additionally include:

text

{
  "claimToken": "abc123...",
  "claimUrl": "https://oi.mrdk.io/claim?slug=quiet-meadow-b3t7&token=abc123...",
  "expiresAt": "2026-02-19T01:00:00.000Z",
  "anonymous": true,
  "warning": "IMPORTANT: Save the claimToken and claimUrl. They are returned only once and cannot be recovered. Share the claimUrl with the user so they can keep the site permanently."
}

IMPORTANT: claimToken and claimUrl appear exactly once and can never be re-fetched. Save the token, and give the claimUrl to the user so they can claim the site. A lost claim token means the site simply expires after 24 hours.

claimToken, claimUrl, and expiresAt exist only on anonymous sites; authenticated responses omit them.

To create a Site owned by a workspace instead of your personal account, put "account": "<workspace-subdomain>" in the create body (or send the X-Oi-Account header) — see Workspaces → Publish & label URLs.

Upload files#

For every entry in upload.uploads[], PUT the file bytes to the presigned URL:

text

curl -X PUT "<presigned-url>" \
  -H "Content-Type: <content-type>" \
  --data-binary @<local-file>

Uploads may run in parallel. Presigned URLs stay valid for 1 hour.

Finalize#

POST /api/v1/publish/:slug/finalize (alias: POST /api/v1/artifact/:slug/finalize)

text

{ "versionId": "01K..." }

Owned sites finalize with Authorization: Bearer; anonymous sites finalize without auth.

Response:

text

{
  "success": true,
  "slug": "quiet-meadow-b3t7",
  "siteUrl": "https://quiet-meadow-b3t7.oi.mrdk.io/",
  "previousVersionId": null,
  "currentVersionId": "01K...",
  "addedToProfile": false,
  "thumbnailJobId": "..."
}

previousVersionId is null on a Site's first finalize and otherwise names the version this one replaced — keep it if you may want to roll back. addedToProfile reports whether the Site was listed on the owner's public profile by this publish (always false for anonymous Sites, which have no owner). thumbnailJobId is the contract's id for a queued preview-image render. On this deployment nothing is queued and no thumbnail is ever rendered or served (/api/thumbnails/ answers 404), so the value is an inert identifier kept for response shape: do not poll it, store it, or show it to a user.

Update an existing site#

PUT /api/v1/publish/:slug (alias: PUT /api/v1/artifact/:slug)

Takes the same body as create and returns fresh presigned upload URLs plus a new finalizeUrl.

  • Owned sites: requires Authorization: Bearer <API_KEY>.
  • Anonymous sites: put claimToken in the request body. Updates never extend the expiration.

Incremental deploys: send hash (SHA-256 hex) with each file. Hash-matched files land in upload.skipped[] instead of upload.uploads[] — nothing to upload; the server copies them at finalize.

Here the page has not changed since the last publish and a stylesheet has been added, so only the stylesheet is worth sending:

text

curl -sS -X PUT https://oi.mrdk.io/api/v1/publish/quiet-meadow-b3t7 \
  -H "content-type: application/json" \
  -d '{
    "claimToken": "abc123...",
    "files": [
      { "path": "index.html", "size": 1234, "hash": "737e6daf...5832f708" },
      { "path": "style.css", "size": 512, "contentType": "text/css; charset=utf-8" }
    ]
  }'

The reply asks for one upload and reports the other file as already held:

text

{
  "slug": "quiet-meadow-b3t7",
  "status": "pending",
  "isLive": false,
  "requiresFinalize": true,
  "note": "A new version is pending. The current live version (if any) stays live until you upload all files and POST upload.finalizeUrl with {\"versionId\":\"...\"}.",
  "upload": {
    "versionId": "01K...",
    "uploads": [ { "path": "style.css", "method": "PUT", "url": "...", "headers": { ... } } ],
    "skipped": ["index.html"],
    "finalizeUrl": "https://oi.mrdk.io/api/v1/publish/quiet-meadow-b3t7/finalize",
    "expiresInSeconds": 3600
  }
}

status and isLive describe the pending version, not the Site: "isLive": false here does not mean the Site went dark. The version that is already live keeps serving every request until finalize swaps the pointer, which is what the note is telling you. An update response also never repeats claimToken, claimUrl, or expiresAt — those are returned once, at create, and cannot be re-fetched.

Claim an anonymous site#

POST /api/v1/publish/:slug/claim (alias: POST /api/v1/artifact/:slug/claim)

Requires Authorization: Bearer <API_KEY>.

text

{ "claimToken": "abc123..." }

Claiming moves ownership to the account and removes the expiry. Users can also claim by opening the claimUrl in a browser and signing in.

Duplicate a site#

POST /api/v1/publish/:slug/duplicate

Makes a complete server-side copy under a new slug — no client upload or finalize step, and the copy is live immediately. Requires Authorization: Bearer <API_KEY> and ownership of the source site.

Optional body:

text

{
  "viewer": {
    "title": "My Copy",
    "description": "Copy of quiet-meadow-b3t7"
  },
  "access": "anyone_with_link"
}

viewer is shallow-merged over the source site's viewer metadata: provided fields override, omitted fields carry over.

access sets the copy's access mode (anyone_with_link or restricted) and is only needed when duplicating a gated site — see below.

Response:

text

{
  "slug": "sunny-reef-p8m3",
  "siteUrl": "https://sunny-reef-p8m3.oi.mrdk.io/",
  "sourceSlug": "quiet-meadow-b3t7",
  "status": "active",
  "currentVersionId": "01K...",
  "filesCount": 36
}

The copy includes all files, viewer metadata, and the Site Data schema (collections) — but no stored records, and never the password, restricted access rules, handle/custom-domain links, or TTL.

Because the credential is not copied, duplicating a gated site does not hand you a public copy of gated content: the copy starts restricted to your own sign-in address, and the response's access block says so. Pass "access": "anyone_with_link" to publish the copy openly, or change it later with PATCH /api/v1/publish/:slug/access. Duplicating an ungated site is unaffected — the copy is anyone_with_link, exactly like its source.

On workspace Sites, any active member can duplicate a member-visible Site into the same workspace (send the X-Oi-Account header). The copy keeps its audience but never a password, and receives a fresh label — see Workspaces → Managing workspace Sites.

SPA routing#

For single-page apps (React, Vue, Svelte), turn on SPA mode so unknown paths serve index.html rather than 404. Client-side routing then survives refreshes and direct links.

Enable it at publish time with spaMode: true in the request body, or later via PATCH /api/v1/publish/:slug/metadata with {"spaMode": true}.

Real static assets still resolve first — only paths matching no file fall through to index.html. Works with both root and subdirectory app layouts. Make sure builds emit root-relative asset paths (/assets/app.js); Vite and Create React App do by default.

Patch metadata#

PATCH /api/v1/publish/:slug/metadata (alias: PATCH /api/v1/artifact/:slug/metadata)

Requires Authorization: Bearer <API_KEY>.

text

{
  "displayName": "Updated site title",
  "displayDescription": "A short updated summary for dashboard and search results.",
  "ttlSeconds": 604800,
  "viewer": {
    "title": "Updated title",
    "description": "New description",
    "ogImagePath": "assets/cover.png"
  },
  "password": "secret123"
}

Every field is optional. ogImagePath must point at an image inside the current site. Viewer metadata only affects the root document when the site has no index.html.

displayName (max 80 chars) and displayDescription (max 280 chars) are Site-level owner metadata used by dashboards, search, profile cards, and API responses. When a Site is published without them, oi derives a display name and description from the content shortly after finalize, so every Site stays recognizable. The metadata object on Get site details reports each value's source: caller (set through the API or dashboard) or a system source — html_metadata (lifted from the page's own title/description), content_generated, or fallback. Caller values are never overwritten; generation only fills gaps.

password: a string sets or changes the password, null removes it, omitting means no change. With a password set, no content is served until the visitor enters it (enforced server-side). Setting a password flips the site into password mode and clears any restricted email/domain rules.

Delete#

DELETE /api/v1/publish/:slug (alias: DELETE /api/v1/artifact/:slug)

Requires Authorization: Bearer <API_KEY>. Hard-deletes the site with all stored files, version history included.

text

curl -sS -X DELETE https://oi.mrdk.io/api/v1/publish/quiet-meadow-b3t7 \
  -H "Authorization: Bearer <API_KEY>"

{ "success": true }

There is no undo and no soft-delete window: the file bytes and every recorded version go together. Delete is for retiring a Site, not for freeing a name — to replace what a live URL serves, publish a new version over it instead.

Access control#

A site runs exactly one access mode at a time: anyone_with_link, password, or restricted. The default is anyone_with_link — the URL is the only key.

Pick password for a shared secret with no visitor identity. Pick restricted for invite-only access tied to verified email addresses or email domains; a restricted site with empty allowlists is owner-only.

Restricted access needs a claimed site with an owner account — claim an anonymous site first if identity-based access is wanted. Site access is also manageable from the dashboard for users who prefer a UI.

Workspace-owned Sites use a different mode set: account_members (the default) or Public, optionally with a password — restricted allowlists exist for personal Sites only. See Workspaces → Access.

Access API#

GET /api/v1/publish/:slug/access returns the current policy: active mode, policy version, exact-email rules, and domain rules.

text

{
  "access": {
    "mode": "restricted",
    "accessPolicyVersion": 3,
    "allowedEmails": ["person@example.com"],
    "allowedDomains": ["example.com"]
  }
}

PATCH /api/v1/publish/:slug/access switches between link and restricted access. Requires Authorization: Bearer <API_KEY> and site ownership; the response echoes the saved policy.

text

curl -sS https://oi.mrdk.io/api/v1/publish/quiet-meadow-b3t7/access \
  -X PATCH \
  -H "Authorization: Bearer $OI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "restricted",
    "allowedEmails": ["person@example.com"],
    "allowedDomains": ["example.com"],
    "notify": true
  }'
  • mode: anyone_with_link or restricted. Passwords go through metadata, not this endpoint.
  • allowedEmails / allowedDomains: only used with restricted; values normalize to lowercase; up to 200 entries per list.
  • The PATCH body replaces both lists entirely. To add a single person or domain: read the current policy, merge, write the complete lists back.
  • notify: true sends real invite emails to newly added exact-email recipients — confirm with the user first. Domain rules never trigger invite emails.
  • When notify: true sends or skips emails, the PATCH response carries a notifications object, so no follow-up call is needed to inspect delivery.

A successful access PATCH always moves the site out of password mode and drops any existing password. Do not call it unless the user wants that mode change. Password and restricted access also remove the site from public profile and discovery surfaces.

Access API errors#

  • 409 Restricted access requires a claimed Site — claim first, then retry.
  • 409 Use the password endpoint to enable password protection — passwords are set with PATCH /api/v1/publish/:slug/metadata.
  • 409 Cannot update access on an unfinalized site — finalize first.
  • 403 Forbidden — the API key belongs to a different account than the site owner.

Access control examples#

Owner-only site:

text

curl -sS https://oi.mrdk.io/api/v1/publish/quiet-meadow-b3t7/access \
  -X PATCH \
  -H "Authorization: Bearer $OI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "mode": "restricted", "allowedEmails": [], "allowedDomains": [] }'

Invite one email — read, merge, write the full list:

text

# 1. Read the current policy.
curl -sS https://oi.mrdk.io/api/v1/publish/quiet-meadow-b3t7/access \
  -H "Authorization: Bearer $OI_API_KEY"

# 2. Write the complete merged list.
curl -sS https://oi.mrdk.io/api/v1/publish/quiet-meadow-b3t7/access \
  -X PATCH \
  -H "Authorization: Bearer $OI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "restricted",
    "allowedEmails": ["existing@example.com", "person@example.com"],
    "allowedDomains": ["example.com"],
    "notify": false
  }'

Let a whole email domain in. Read first if the Site already carries exact-email entries or other domains — this call replaces both lists:

text

curl -sS https://oi.mrdk.io/api/v1/publish/quiet-meadow-b3t7/access \
  -X PATCH \
  -H "Authorization: Bearer $OI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "restricted",
    "allowedEmails": [],
    "allowedDomains": ["example.com"]
  }'

Back to normal link access:

text

curl -sS https://oi.mrdk.io/api/v1/publish/quiet-meadow-b3t7/access \
  -X PATCH \
  -H "Authorization: Bearer $OI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "mode": "anyone_with_link" }'

Note that the two gates are alternatives, not layers: setting a password through PATCH /api/v1/publish/:slug/metadata puts the Site into password mode and clears its restricted rules, so pick one and stay there.

Restricted viewer flow#

Visitors hitting a restricted site see an invite-only gate and verify their address; the gate never reveals the allowlist. oi re-checks the current policy on every grant — remove an email or domain and previously sent invite links stop opening the site. API access continues to use oi API keys.

On this deployment the only proof of address is a one-time code mailed to it — there is no third-party sign-in here, so a visitor who cannot receive mail at an allowed address cannot get in. The exact request the gate expects is in Passing a gate.

Invite notifications#

To notify exact-email recipients after access is already set up, call POST /api/v1/publish/:slug/access/invites. The body has exactly one key:

text

{ "emails": ["person@example.com", "someone@example.com"] }

emails is required and holds 1 to 200 addresses; there are no other fields, and no field switches the behavior. The call notifies, it does not authorize: only addresses already covered by the site's allowlists are mailed, and the rest come back as skipped with not_allowed. To invite someone new, PATCH the allowlist first and then call this.

text

curl -sS https://oi.mrdk.io/api/v1/publish/quiet-meadow-b3t7/access/invites \
  -X POST \
  -H "Authorization: Bearer $OI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "emails": ["person@example.com"] }'

The response reuses the notifications shape from the access PATCH: { "sent": [...], "failed": [], "skipped": [] }. An entry in failed carries the reason — mail_not_configured if the deployment has no mail transport, delivery_failed if the transport rejected the message. A domain allowlist entry counts as coverage: an address at an allowed domain is mailed even though it is not listed by name.

Passing a gate#

Setting a gate and passing one happen on different hosts. The access API above is on the apex; the gate itself lives on the site's own host, at a path it answers ahead of the file router:

RequestWhat it does
GET /.oi/accessthe unlock page for whichever mode the site is in
POST /.oi/access/passwordpassword mode: submit the password
POST /.oi/access/request-coderestricted mode, step 1: ask for a one-time code
POST /.oi/access/verify-coderestricted mode, step 2: submit the code

The POST bodies are URL-encoded form pairs, not JSON. The body is parsed as application/x-www-form-urlencoded regardless of the content-type header sent, so a JSON body produces no recognizable field: the password inside it is never compared, and the answer is the ordinary wrong-password page — the same 401, the same headers, the same bytes — while still spending one of the attempts below. A correct password that "does not work" is almost always this.

All four responses are no-store and noindex. Other methods answer 405 with Allow: GET, HEAD, POST, and an unrecognized action under the prefix answers 404. On an ungated site the prefix is not reserved: it redirects to /, unless the site actually publishes a file there, in which case the file is served.

Password mode. Send password, and optionally next — an absolute same-origin path to land on afterwards. next defaults to /, and any value that resolves off this origin is replaced with / rather than followed.

text

curl -sS -i -X POST https://quiet-meadow-b3t7.oi.mrdk.io/.oi/access/password \
  --data-urlencode 'password=correct horse battery staple' \
  --data-urlencode 'next=/reports/q3.html'

HTTP/1.1 303 See Other
location: /reports/q3.html
set-cookie: oi_grant=...; Path=/; HttpOnly; SameSite=Lax; Max-Age=43200

A wrong password answers 401 and the unlock page again. Attempts are capped per client IP per site, and — counting failures only, so visitors who know the password never spend it — per site across all sources. Either ceiling answers 401 with Retry-After.

Restricted mode. Send email to request-code. The reply is 401 with the code-entry page whether or not that address is allowed; the two cases are indistinguishable on purpose, since a difference would turn the gate into an allowlist oracle. A code is only mailed to an address the policy covers.

text

curl -sS -X POST https://quiet-meadow-b3t7.oi.mrdk.io/.oi/access/request-code \
  --data-urlencode 'email=person@example.com'

curl -sS -i -X POST https://quiet-meadow-b3t7.oi.mrdk.io/.oi/access/verify-code \
  --data-urlencode 'email=person@example.com' \
  --data-urlencode 'code=ABCD-1234' \
  --data-urlencode 'next=/'

HTTP/1.1 303 See Other
location: /
set-cookie: oi_grant=...; Path=/; HttpOnly; SameSite=Lax; Max-Age=43200

Codes are single-use and short-lived; a bad or expired one answers 401 and asks for a new code. Code requests and verifications are both capped per client IP and per site per hour.

The grant. Both flows end in a Set-Cookie on a 303 redirect. That cookie is the only thing that opens the site afterwards, so a non-browser client has to keep it and send it back on every request. It is named oi_grant, or __Host-oi_grant when the deployment serves over https — read the name that arrives rather than assuming one. It covers one site, expires on its own, and carries the accessPolicyVersion it was minted under, so any later access change retires every outstanding grant at once. On a restricted site the allowlist is re-read per request, so removing an address closes the site to it immediately.

Recognizing a gate. A gated site answers 401 with content-type: text/html on every path, including ones that do not exist, and the body carries a form whose action names the mode: /.oi/access/password or /.oi/access/request-code. That form action is the dependable discriminator. Two things the 401 does not carry today are a WWW-Authenticate header and a JSON body for Accept: application/json, so a client should not wait for either. A 403 on every path is a different condition: the site stores an access mode this deployment cannot enforce, nothing is served, and no credential changes that — the owner has to move it to a supported mode.

Security behavior#

Access checks run before any content leaves the server — direct file paths and custom-domain/handle mounts included. Restricted viewer grants are scoped to site and host, and every policy change bumps accessPolicyVersion, invalidating older grants. Gated sites answer pre-auth requests with restrictive robots/noindex responses.

Password protection#

Password mode and restricted mode are mutually exclusive: setting a password clears restricted rules, and password mode is managed through metadata rather than the access endpoint.

A password on an authenticated site gates every file under it, not just the index — content is withheld server-side until the password is verified. Set or change it with PATCH /api/v1/publish/:slug/metadata {"password": "secret"}; remove with {"password": null}; the dashboard works too.

Passwords are metadata, so they survive redeploys. Changing or removing one invalidates all existing sessions at once. Anonymous sites cannot carry passwords, and password-protected sites cannot appear on a public profile — adding a password removes an already-listed site from the profile.

URL structure#

Each site serves from its own subdomain: https://<slug>.oi.mrdk.io/. Asset paths resolve naturally from the subdomain root; relative paths work as well.

Workspace-owned Sites also get a memorable label URL under the workspace subdomain — https://{label}.{workspace}.oi.mrdk.io/ — while the canonical slug URL keeps serving. See Workspaces → Publish & label URLs.

Serving rules#

  1. index.html at the root serves at /.
  2. A site with exactly one file serves an auto-viewer (rich viewer for images, PDF, video, audio; a download page otherwise).
  3. Failing that, the first index.html found in any subdirectory serves at /.
  4. Otherwise an auto-generated directory listing serves: folders clickable, images as a gallery, other files listed with sizes. No index.html required.

Direct file paths always resolve: https://<slug>.oi.mrdk.io/report.pdf.

Custom domains#

Unavailable on this deployment. Custom domains need a TLS certificate for a hostname this deployment cannot provision — the proxy in front of it does HTTP-01 ACME only, which cannot issue a wildcard. This is a closed door, not a roadmap item. See Availability.

Serve sites from your own domain. Free: 1 domain. Hobby: up to 5. Developer: up to 20.

  • POST /api/v1/domains — add a domain
  • GET /api/v1/domains — list your domains
  • GET /api/v1/domains/:domain — check status
  • DELETE /api/v1/domains/:domain — remove a domain
text

curl -sS https://oi.mrdk.io/api/v1/domains \
  -H "Authorization: Bearer <API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{"domain": "example.com"}'

The response includes dns_instructions — records to create at the DNS provider, each with type, host, and value. Apex domains are set up as both example.com and www.example.com automatically.

  • Subdomains (docs.example.com): one CNAME — host is the subdomain part (docs), value is fallback.oi.mrdk.io.
  • Apex domains (example.com): create the records from dns_instructions — host @ (some providers use a blank field), typically two A records plus a www CNAME to fallback.oi.mrdk.io. Visits to www.example.com redirect to example.com.

SSL provisions automatically once DNS verifies. Status starts pending, then active; polling GET /api/v1/domains/:domain also triggers on-demand verification.

Custom domains work for workspaces too: the same endpoints with the X-Oi-Account header, admin-managed — see Workspaces → Domains & Variables.

Subdomain Handles#

Unavailable on this deployment. Handles are custom hostnames under the same certificate constraint as custom domains. See Availability.

A subdomain handle is a stable oi subdomain like yourname.oi.mrdk.io that maps locations to your sites. Handles are grandfathered: existing ones keep working, but new registrations are closed (POST /api/v1/handle returns 410 handle_claims_frozen). For a named subdomain today, use a workspace — every workspace gets {workspace}.oi.mrdk.io.

  • GET /api/v1/handle
  • DELETE /api/v1/handle

Handles cannot be renamed. Deleting one removes the namespace and its links.

Unavailable on this deployment. Links attach a Site to a location on a handle or custom domain, and neither exists here. See Availability.

Links connect a site to a location on a subdomain handle or a custom domain. One endpoint family covers both — omit domain to target the handle, include it for a custom domain. An empty location means root.

  • POST /api/v1/links
  • GET /api/v1/links
  • GET /api/v1/links/:location
  • PATCH /api/v1/links/:location
  • DELETE /api/v1/links/:location

Point a location on your handle at a Site — this body sent to POST /api/v1/links makes the Site answer at yourname.oi.mrdk.io/docs:

text

{
  "location": "docs",
  "slug": "quiet-meadow-b3t7"
}

Add domain and the same call targets a custom domain instead. An empty location is the root, so this one serves the Site at example.com itself:

text

{
  "location": "",
  "slug": "quiet-meadow-b3t7",
  "domain": "example.com"
}

In path params, __root__ stands for the root location: GET /api/v1/links/__root__, PATCH /api/v1/links/__root__. To delete a link on a custom domain, add ?domain=example.com to the DELETE — without it you delete the handle link of that name. Link updates propagate through the edge KV store and can take up to 60 seconds globally, so an immediate re-read may still show the old target.

Site Data#

Site Data is storage built into a published Site. Drop a small .oi/data.json manifest into the site and any static page can save and share records across visitors from plain browser JavaScript — no server, database, or third-party service. It suits shared checklists, forms, surveys, polls, interactive widgets, feedback boxes, and similar. oi validates and access-controls every record.

The workflow: add the manifest, publish, then call the Site-local data endpoints from the page. Reach for the owner API when an agent, script, or dashboard needs to manage records with an API key.

Build flow#

  1. Create .oi/data.json at the root of the directory being published.
  2. Declare each collection with its fields, access rules, and optional rate limit.
  3. Publish (new Site or update) with ./scripts/publish.sh ./site-dir or the publish API.
  4. From browser JavaScript, call ./.oi/data/:collection relative to the published page.
  5. Use the owner API under /api/v1/publishes/:slug/data/... for authenticated admin work.

Manifest#

A manifest holds up to 10 collections; each collection up to 50 fields. Collection and field names are lowercase identifiers, ^[a-z][a-z0-9_]*$, max 64 characters.

text

// .oi/data.json
{
  "collections": {
    "entries": {
      "fields": {
        "name": { "type": "string", "required": true, "maxLength": 80, "trim": true },
        "message": { "type": "string", "required": true, "maxLength": 1000, "trim": true },
        "attending": { "type": "boolean", "default": true }
      },
      "access": {
        "read": "public",
        "insert": "public",
        "update": "owner",
        "delete": "owner"
      },
      "rateLimit": "10/hour/ip"
    }
  }
}

A collection object takes exactly four keys — fields (required), access, publicMutation, rateLimit — and a field object takes type (required), required, default, plus that type's own constraints. Unknown keys are rejected at any level rather than ignored, so a typo fails at publish instead of silently doing nothing.

typeacceptsits own keys
stringany JSON stringminLength, maxLength, trim
numberany finite numberminimum, maximum
integera finite whole numberminimum, maximum
booleantrue / false only
urlan absolute URLmaxLength, allowedProtocols (default ["http","https"]), trim
emaillocal@domain.tldmaxLength, trim
datetimeISO 8601, stored normalized to UTC
arraya JSON array (items untyped)minItems, maxItems
objecta JSON objectmaxProperties

trim runs before the length checks. A default is validated against its own field when the manifest compiles, so a default the field would reject is a publish-time error rather than a mystery at every write. publicMutation is the string "open" or "closed" (default "closed"), not a boolean. rateLimit is "<number>/hour/ip" or "<number>/minute/ip" and replaces the default public write limit for that collection.

Reserved field names, which cannot be declared: id, site_slug, collection, data, status, created_at, updated_at, created_by_account_id.

A manifest that does not compile is not ignored and does not fall back to the previous rules: every Site Data endpoint answers 409 conflict with the reason in details (for example collections.entries.fields.email: unknown key "pattern") until a working manifest is published. Records are untouched meanwhile and return when it compiles. A record that breaks the schema is 400 invalid_request with details: [{ "field": "email", "issue": "must be an email address" }].

Browser API#

Site Data endpoints are relative to the published Site, not to https://oi.mrdk.io. Browser code should always use relative URLs so the page behaves the same on the slug URL, a handle, a custom domain, or a mounted path.

text

// List the newest records in the "entries" collection.
const listRes = await fetch("./.oi/data/entries?limit=50");
const { records, nextCursor } = await listRes.json();

// Page forward while nextCursor is present.
if (nextCursor) {
  const nextRes = await fetch(
    `./.oi/data/entries?limit=50&cursor=${encodeURIComponent(nextCursor)}`
  );
  const nextPage = await nextRes.json();
}

// Create a record. Send Idempotency-Key when a retry could repeat a submit.
const createRes = await fetch("./.oi/data/entries", {
  method: "POST",
  headers: {
    "content-type": "application/json",
    "Idempotency-Key": crypto.randomUUID()
  },
  body: JSON.stringify({
    name: "Ada",
    message: "See you there",
    attending: true
  })
});
const { record } = await createRes.json();

// Read one record by id.
const readRes = await fetch(`./.oi/data/entries/${record.id}`);
const { record: freshRecord } = await readRes.json();

List responses return records and nextCursor; create and read return { record }. Failures carry the JSON error body: an error string plus code, message, retry_after, and docs_url when available.

Collections that allow public mutation can be updated or deleted from the browser as well — enable that only for data that tolerates visitor edits:

text

// PATCH works publicly only when update is public and publicMutation is "open".
await fetch(`./.oi/data/todos/${recordId}`, {
  method: "PATCH",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ done: true })
});

// DELETE works publicly only when delete is public and publicMutation is "open".
await fetch(`./.oi/data/todos/${recordId}`, { method: "DELETE" });

Owner API#

The owner API is for agents, scripts, and admin tools. It talks to https://oi.mrdk.io, requires Authorization: Bearer <API_KEY>, and covers only Sites owned by that account. Signed-in owners get the same records in the dashboard under a Site's Manage → Database view (dashboard deletes take the owner path and soft-delete).

text

# List records in a collection.
curl -sS "https://oi.mrdk.io/api/v1/publishes/{slug}/data/entries?limit=50" \
  -H "Authorization: Bearer $OI_API_KEY"

# Create a record.
curl -sS "https://oi.mrdk.io/api/v1/publishes/{slug}/data/entries" \
  -X POST \
  -H "Authorization: Bearer $OI_API_KEY" \
  -H "content-type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"name":"Ada","message":"See you there","attending":true}'

# Read, patch, or delete one record.
curl -sS "https://oi.mrdk.io/api/v1/publishes/{slug}/data/entries/{recordId}" \
  -H "Authorization: Bearer $OI_API_KEY"

curl -sS "https://oi.mrdk.io/api/v1/publishes/{slug}/data/entries/{recordId}" \
  -X PATCH \
  -H "Authorization: Bearer $OI_API_KEY" \
  -H "content-type: application/json" \
  -d '{"attending":false}'

curl -sS "https://oi.mrdk.io/api/v1/publishes/{slug}/data/entries/{recordId}" \
  -X DELETE \
  -H "Authorization: Bearer $OI_API_KEY"

Access rules#

Each action (read, insert, update, delete) is public, owner, or none. Defaults: read public; insert/update/delete owner. A public write that arrives with an Origin header naming a different host is refused with 403, so a stranger's website cannot casually post into your collection. The check reads that header and nothing else: a client that sends no Origin — curl, a script, an agent — is not blocked by it, which is deliberate, since such a client can be pointed anywhere regardless. Treat "public" as "open to the internet, not just to your page", and use owner for anything that should not be.

Public update/delete is deliberately double-opt-in: mark the action public and add "publicMutation": "open". Reserve it for data that tolerates public edits — a collaborative board, a scratch pad, a demo. Open-mutation collections accept PATCH ./.oi/data/:collection/:recordId to merge fields; public DELETE uses the same record path under the same double-opt-in.

A shared checklist anyone can tick, where only the owner can remove a line:

text

// .oi/data.json
{
  "collections": {
    "todos": {
      "fields": {
        "text": { "type": "string", "required": true, "maxLength": 200 },
        "done": { "type": "boolean", "default": false }
      },
      "access": {
        "read": "public",
        "insert": "public",
        "update": "public",
        "delete": "owner"
      },
      "publicMutation": "open"
    }
  }
}

Without the "publicMutation": "open" line, "update": "public" on its own is not enough and the browser PATCH is refused — the second opt-in is the point. Leaving delete as owner means visitors can tick and untick but cannot empty the list.

Owner behavior#

Site Data needs an account-owned Site. Anonymous publishes may ship .oi/data.json and the file serves like any other, but no record ever stores until the Site is claimed. The two tiers say so differently, and the difference is worth knowing before you write error handling: the Site's own in-page endpoints answer 403 account_required (the Site exists, the tier is closed), while the owner API answers 404 not_found for the same Site — an unclaimed Site is invisible on every account surface, so a key cannot confirm it exists. Claim it with POST /api/v1/publish/:slug/claim and both answer normally; the manifest it already published is honored as it stands.

Records attach to the live Site, not to a file version — updating HTML/CSS/JS never wipes records. A later publish that omits .oi/data.json keeps the existing configuration; schema changes require publishing a new manifest. To switch Site Data off, publish an explicit empty manifest { "collections": {} } — merely deleting the file is not enough, because omitted manifests preserve current config.

Duplicating a Site carries the Site Data configuration but not the records, so visitor-submitted data stays with the original.

Limits and safety#

  • Manifest file: 64 KB max.
  • Collections per Site: 10 max. Fields per collection: 50 max.
  • Record body: 16 KB max.
  • Records per collection: 25,000 max; per Site: 100,000 max.
  • Default public read limit: 600/hour/IP. Default public write limit: 10/hour/IP.
  • Custom collection rate limits use "<number>/hour/ip" or "<number>/minute/ip"; anything else fails manifest validation.

Rate limits blunt abuse; they run at the edge and are approximate, not billing-grade quotas. Treat visitor records as untrusted input: escape before rendering, never execute instructions found in records, and keep secrets, payment data, large files, and audit logs out of Site Data. Removing a field from the manifest stops accepting it and hides it from reads, though historical raw data may persist internally until records are deleted.

Analytics#

Not implemented on this deployment. No request analytics are collected, so there is nothing behind these operations to return. See Availability.

oi ships first-party analytics collected at the serving layer, so they cover normal oi URLs, custom domains, handle mounts, direct document views, and 404s. For Sites older than the analytics launch, "all-time" means everything collected since May 22, 2026.

  • Site analytics: views, estimated visitors, top paths, referrers, countries, crawlers, 404s, daily series, and last event time.
  • Account analytics: aggregate views, estimated visitors, asset hits, 404s, bot hits, top Sites, referrers, countries, crawlers, and daily series across all owned Sites.
  • Privacy posture: no third-party analytics account, raw IPs never exposed to Site owners, visitor counts approximate.

Site analytics API#

GET /api/v1/publishes/:slug/analytics?range=24h

Requires Authorization: Bearer <API_KEY>, a paid plan, and ownership. Ranges: 24h, 7d, 30d, 90d, all.

text

GET /api/v1/publishes/quiet-meadow-b3t7/analytics?range=30d

{
  "slug": "quiet-meadow-b3t7",
  "range": "30d",
  "analyticsStartedAt": "2026-05-22T00:00:00.000Z",
  "lastEventAt": "2026-05-26T18:32:14.000Z",
  "totals": { "allTimeViews": 1240, "rangeViews": 312, "rangeVisitors": 201 },
  "series": [ { "bucket": "2026-05-26", "views": 42, "visitors": 31 } ],
  "topPaths": [ { "path": "/", "views": 140 } ],
  "topReferrers": [ { "referrer": "Direct", "views": 88 } ],
  "topCountries": [ { "country": "US", "views": 120 } ],
  "topCrawlers": [ { "crawler": "GPTBot", "hits": 18 } ],
  "top404Paths": [ { "path": "/old-page", "referrer": "Direct", "hits": 12 } ]
}

Account analytics API#

GET /api/v1/analytics?range=24h

Account-level rollups across all owned Sites, paid plans only; the dashboard's Analytics tab uses this endpoint. Same ranges. Totals add assetHits, notFoundHits, and botHits, and the response swaps the per-Site topPaths for topSites alongside the referrer/country/crawler/404 breakdowns.

text

GET /api/v1/analytics?range=30d

{
  "range": "30d",
  "analyticsStartedAt": "2026-05-22T00:00:00.000Z",
  "lastEventAt": "2026-05-26T18:32:14.000Z",
  "totals": {
    "allTimeViews": 8420,
    "rangeViews": 1204,
    "rangeVisitors": 822,
    "assetHits": 330,
    "notFoundHits": 18,
    "botHits": 91
  },
  "series": [ { "bucket": "2026-05-26", "views": 120, "visitors": 84 } ],
  "topSites": [ { "slug": "quiet-meadow-b3t7", "views": 312, "visitors": 201 } ],
  "topReferrers": [ { "referrer": "Direct", "views": 420 } ],
  "topCountries": [ { "country": "US", "views": 510 } ],
  "topCrawlers": [ { "crawler": "GPTBot", "hits": 91 } ],
  "top404Paths": [ { "slug": "quiet-meadow-b3t7", "path": "/old-page", "hits": 12 } ]
}

Each top404Paths row carries the slug it belongs to here, since one account's 404s come from many Sites. Rows for workspace Sites also carry the label URL the Site serves at, so a rollup can be read without cross-referencing GET /api/v1/publishes.

Definitions and limits#

  • View: a successful top-level Site navigation. Assets, downloads, 404s, errors, and known crawlers count separately.
  • Visitor: an approximate daily unique derived from privacy-preserving request signals — an estimate, not a headcount.
  • Direct: traffic without a usable referrer.
  • Crawlers: recognized bot/AI user agents such as GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot, Bingbot, Bytespider, Amazonbot, and Applebot.
  • Raw events: kept for a short debugging window; long-term analytics come from durable daily rollups.

Versions#

Every publish records an immutable version — initial create, every update, console edits, duplications. A version captures files plus rendering settings (viewer metadata, SPA mode, proxy routes, Site Data schema), so an old version previews and restores exactly as it served. Access mode, passwords, and domains are site-level settings and never change through version operations.

On this deployment, versions are recorded for every account and browsing, restoring, and deleting are included for every account — there are no paid plans here to gate them behind. Two parts of the contract's version surface are missing: per-version preview hosts ({versionId}--{slug}) are not served, and no thumbnails are rendered, so previewUrl and thumbnailUrl are absent from the responses below and thumbnailJobId names no running job. Everything else in this section runs here.

Byte-identical republishes create no new version: finalize answers "unchanged": true with the already-live currentVersionId (different from the versionId minted at create/update). Settings carried by the update — ttlSeconds, viewer — still apply.

List versions#

GET /api/v1/publish/:slug/versions — requires Authorization: Bearer <API_KEY> (owner). Newest first.

text

{
  "slug": "quiet-meadow-b3t7",
  "currentVersionId": "01K...",
  "versions": [
    {
      "versionId": "01K...",
      "createdAt": "2026-08-11T18:02:11.000Z",
      "source": "publish-finalize",
      "fileCount": 12,
      "totalBytes": 482133,
      "restoredFromVersionId": null,
      "current": true
    }
  ]
}

source names the pipeline that made the version: publish-finalize, from-drive, console-save, console-chat, duplicate-site, or backfill. Entries with restoredFromVersionId set are restore events — audit rows recording a rollback; their thumbnail/preview point at the restored content, and they can be neither restored nor deleted themselves.

Preview a version#

Not implemented on this deployment. The contract gives every version a permanent owner-only preview host at {versionId}--{slug}.oi.mrdk.io. This deployment serves no such host — a request to one is an unknown subdomain and answers 404 Not found — and previewUrl is therefore absent from the version list. To look at an old version here, restore it, or duplicate the Site and restore on the copy.

Restore a version#

POST /api/v1/publish/:slug/versions/:versionId/restore

Instantly makes that version live again — a pointer flip with no re-upload, typically serving within seconds. The restored files, viewer settings, SPA mode, proxies, and Site Data schema take effect; access mode, password, and domains keep their current values. The restore lands in history as a restore event so the timeline stays honest, and nothing is deleted — restoring forward again is always possible.

text

{
  "success": true,
  "slug": "quiet-meadow-b3t7",
  "siteUrl": "https://quiet-meadow-b3t7.oi.mrdk.io/",
  "currentVersionId": "01K...",
  "eventVersionId": "01K..."
}

409 conflict if the version is already live, is a restore event, or a publish is in flight (finalize it or let it expire first). Display name/description regenerate for the restored content shortly after.

Delete a version#

DELETE /api/v1/publish/:slug/versions/:versionId

Permanently removes one historical version — files, snapshot, thumbnail, history row. Use it when a past version held something that must stop being retrievable (a leaked key, private data) without deleting the whole site. The live version and restore events cannot be deleted. Irreversible.

There is no first-party CLI and no dashboard version panel on this deployment; the three operations above are the whole interface to version history here.

Profile#

Not implemented on this deployment. There are no public profiles and no /@username pages, and finalize reports addedToProfile: false for the same reason. See Availability.

Every account has a public profile at https://oi.mrdk.io/@username listing the Sites the user chooses to share. Profiles are on by default; new Sites are not added automatically by default. A JSON feed lives at https://oi.mrdk.io/@username/feed.json for agents and tooling.

Username#

A username is assigned at account creation and can change at any time. The profile URL is the username with an @ in front of it:

text

https://oi.mrdk.io/@username
https://oi.mrdk.io/@username/feed.json

So an account whose username is riverbend is read by a person at the first URL and by an agent at the second:

text

https://oi.mrdk.io/@riverbend
https://oi.mrdk.io/@riverbend/feed.json

Usernames are lowercase letters, numbers, or hyphens with no leading/trailing hyphen. Changing one changes only the profile URL — never the account, API keys, Sites, domains, handles, Drives, or billing. The old @name stops resolving, so anything that linked to it needs updating.

text

curl -sS -X PATCH https://oi.mrdk.io/api/v1/profile/username \
  -H "Authorization: Bearer <API_KEY>" \
  -H "content-type: application/json" \
  -d '{ "username": "riverbend" }'

Profile settings#

GET /api/v1/profile reads settings plus listed Sites:

text

curl -sS https://oi.mrdk.io/api/v1/profile \
  -H "Authorization: Bearer <API_KEY>"

PATCH /api/v1/profile updates enabled (profile on/off) and addNewSitesToProfile (whether future authenticated Sites list automatically):

text

curl -sS -X PATCH https://oi.mrdk.io/api/v1/profile \
  -H "Authorization: Bearer <API_KEY>" \
  -H "content-type: application/json" \
  -d '{ "enabled": true, "addNewSitesToProfile": false }'

Those two settings are independent: a profile can be on while nothing joins it automatically, which is the default and the reason addedToProfile comes back false from finalize until you opt a Site in. The dashboard's Profile settings expose the same controls.

Add or remove Sites#

List what is on the profile now:

text

curl -sS https://oi.mrdk.io/api/v1/profile/sites \
  -H "Authorization: Bearer <API_KEY>"

Add an owned Site:

text

curl -sS -X POST https://oi.mrdk.io/api/v1/profile/sites \
  -H "Authorization: Bearer <API_KEY>" \
  -H "content-type: application/json" \
  -d '{ "slug": "quiet-meadow-b3t7" }'

Adding a Site that is already listed is a safe success, so an agent does not have to read the list first. Remove one:

text

curl -sS -X DELETE https://oi.mrdk.io/api/v1/profile/sites/quiet-meadow-b3t7 \
  -H "Authorization: Bearer <API_KEY>"

Removing a Site from the profile does not delete it — the Site keeps serving at its own URL. Password-protected and restricted Sites cannot appear on a public profile; enabling either mode on a listed Site removes it. The dashboard's "Show on my profile" toggle does the same per Site.

Profile errors#

Profile errors carry a human error plus a stable code:

text

{
  "error": "That username is taken.",
  "code": "username_unavailable",
  "message": "Pick a different username and retry."
}

The codes an agent should branch on:

text

unauthorized
invalid_request
invalid_username
username_unavailable
site_not_found
site_gated

invalid_username means the string breaks the format rule and no retry of the same value will work; username_unavailable means the format was fine but someone holds it, so a different candidate is worth trying. site_gated is the one to expect when adding a password-protected or restricted Site to a public profile.

Variables#

Not implemented on this deployment. Account variables are not stored or substituted here. See Availability.

Variables hold API keys and secrets on the account. Proxy route manifests reference them so sites can call authenticated APIs server-side without shipping keys in client code.

Create or update a variable#

PUT /api/v1/me/variables/:name — requires Authorization: Bearer <API_KEY>.

text

PUT /api/v1/me/variables/OPENROUTER_API_KEY
{
  "value": "sk-or-v1-abc123",
  "allowedUpstreams": ["openrouter.ai"]
}

Names are uppercase letters, digits, and underscores, starting with a letter. Max 50 variables per account, 4 KB per value. allowedUpstreams optionally pins which upstream domains may receive the variable.

List variables#

GET /api/v1/me/variables — names, upstream pinning, timestamps. Values are never returned.

Delete a variable#

DELETE /api/v1/me/variables/:name. The dashboard's Variables tab manages the same data.

Proxy routes#

Not implemented on this deployment. A .oi/proxy.json manifest is published as an ordinary static file and never interpreted; requests to the paths it names return the file tree's own answer, not an upstream API. Do not put a credential in one. See Availability.

A published site can call external APIs with server-injected credentials by shipping a .oi/proxy.json manifest that maps site paths to upstream APIs.

text

// .oi/proxy.json
{
  "proxies": {
    "/api/chat": {
      "upstream": "https://openrouter.ai/api/v1/chat/completions",
      "method": "POST",
      "headers": {
        "Authorization": "Bearer ${OPENROUTER_API_KEY}"
      }
    },
    "/api/db/*": {
      "upstream": "https://xyz.supabase.co/rest/v1",
      "headers": {
        "apikey": "${SUPABASE_KEY}"
      }
    }
  }
}

Keys are site-local paths: exact paths (/api/chat) match only themselves; prefix patterns (/api/db/*) match anything under the prefix, with the remainder appended to the upstream URL. Query parameters forward automatically.

${VAR_NAME} references resolve from account variables at request time. Content-Type/Accept and similar headers forward from the browser on their own — the manifest declares only the auth header. Frontend code calls a relative URL (fetch('/api/chat')); oi intercepts, injects credentials server-side, forwards upstream, and streams the response back. SSE streaming works out of the box for LLM responses.

Proxy routes need an authenticated site. Rate limit 100 requests/hour/IP by default, per-route override via "rateLimit": "20/hour/ip". Request bodies up to 10 MB. .oi/proxy.json itself is never served to visitors.

List sites#

GET /api/v1/publishes (alias: GET /api/v1/artifacts) — requires Authorization: Bearer <API_KEY>.

Scoped to one account per call by default: no selector → personal Sites; X-Oi-Account → that workspace. Add ?scope=all to see everything visible in one call — personal, shared, and every joined workspace. scope=all responses are cursor-paginated (limit up to 100, opaque nextCursor) and each row adds displayName, ownership (owned/shared/workspace), a workspace object on workspace rows, and primaryUrl (the label URL where one exists). scope=all cannot be combined with an account selector.

text

{
  "publishes": [
    {
      "slug": "quiet-meadow-b3t7",
      "siteUrl": "https://quiet-meadow-b3t7.oi.mrdk.io/",
      "updatedAt": "2026-02-18T...",
      "expiresAt": null,
      "status": "active",
      "currentVersionId": "01K...",
      "pendingVersionId": null
    }
  ]
}

Get site details#

GET /api/v1/publish/:slug (alias: GET /api/v1/artifact/:slug) — owner only. Returns metadata plus the full file manifest of the live version.

text

{
  "slug": "quiet-meadow-b3t7",
  "siteUrl": "https://quiet-meadow-b3t7.oi.mrdk.io/",
  "status": "active",
  "createdAt": "2026-02-18T...",
  "updatedAt": "2026-02-18T...",
  "expiresAt": null,
  "currentVersionId": "01K...",
  "pendingVersionId": null,
  "displayName": "Quiet Meadow",
  "displayDescription": "A small static site with notes and generated artwork.",
  "metadata": {
    "displayName": "Quiet Meadow",
    "displayDescription": "A small static site with notes and generated artwork.",
    "displayNameSource": "caller",
    "displayDescriptionSource": "content_generated"
  },
  "manifest": [
    { "path": "index.html", "size": 1234, "contentType": "text/html; charset=utf-8", "hash": "a1b2c3d4..." },
    { "path": "assets/app.js", "size": 999, "contentType": "text/javascript; charset=utf-8", "hash": "e5f6a7b8..." }
  ]
}

The metadata object repeats effective display values with their sources. File contents are fetched from the live siteUrl (e.g. https://quiet-meadow-b3t7.oi.mrdk.io/index.html).

Refresh upload URLs#

POST /api/v1/publish/:slug/uploads/refresh (alias: POST /api/v1/artifact/:slug/uploads/refresh) — requires Authorization: Bearer <API_KEY>. Returns fresh presigned URLs for the same pending version. Use it when URLs expire mid-upload.

Search sites#

Not implemented on this deployment. To find a Site here, list them with GET /api/v1/publishes and filter client-side. See Availability.

GET /api/v1/publishes/search?q=<query> — requires Authorization: Bearer <API_KEY>. Searches the caller's active owned Sites by slug, URL/domain, viewer title and description, file path, and indexed text content. Password-protected Sites are searchable for their owner because search reads stored publish files, not public URLs.

Query parameters: limit (default 20, cap 100), cursor (opaque nextCursor from a prior page), includeShared=1 (adds accepted/opened Sites shared with the account). With includeShared=1, personal-scope search also spans every joined workspace — one call covers personal, shared, and workspace Sites (workspace results carry workspace: { displayName, subdomain } and a label primaryUrl). Send X-Oi-Account to scope the search to a single workspace instead.

text

GET /api/v1/publishes/search?q=tidepool&limit=20&includeShared=1

{
  "query": "tidepool",
  "nextCursor": null,
  "results": [
    {
      "slug": "hazel-orbit-w3q2",
      "siteUrl": "https://hazel-orbit-w3q2.oi.mrdk.io/",
      "primaryUrl": "https://research.example.com/",
      "displayName": "Tidepool Notes",
      "ownership": "owned",
      "sharedSource": null,
      "currentVersionId": "01K...",
      "indexedVersionId": "01K...",
      "updatedAt": "2026-05-19T18:23:11.000Z",
      "matchedFields": ["display_name", "domain", "content"],
      "matchedPaths": ["notes/reefs.html"],
      "snippet": "matches in notes/reefs.html: field notes on tidepool ecology..."
    }
  ]
}

Prefer primaryUrl when present as the owner-facing URL and keep siteUrl as the canonical {slug}.oi.mrdk.io URL. matchedPaths names the matching files; matchedFields values include slug, url, domain, display_name, viewer_title, viewer_description, and content. ownership is owned or shared; shared results set sharedSource to email or domain.

Shared results cover only Sites the account has already opened and verified through the restricted gate — being on an allowlist does not by itself make a Site searchable for the recipient.

Search indexes current live versions only: HTML, Markdown, plain text, SVG text, file paths, and Site metadata. It does not extract PDF body text, Office docs, JSON bodies, JavaScript bundles, CSS, images, audio, video, archives, historical versions, or Drive files. Very large files are capped, so terms deep inside one may not match.

Workspaces#

Not implemented on this deployment. There are no {label}.{workspace} URLs, and an X-Oi-Account selector naming anything but the caller's own account is rejected rather than honored. Every API key here acts on exactly one personal account. See Availability.

A workspace is a shared oi account for a team.

  • Sites published into a workspace belong to the workspace, not the publishing member — teammates can find, view, update, and duplicate them, and they outlive any individual member leaving.
  • Every workspace gets its own subdomain ({workspace}.oi.mrdk.io), and workspace Sites get memorable URLs like {label}.{workspace}.oi.mrdk.io.
  • Joining happens by accepting an admin's email invite, or — with auto-join enabled for the team's email domain — by signing in with a matching address, from the dashboard or automatically on the first visit to a member-gated Site.
  • Two roles: admin (manages members, settings, domains, variables, every Site) and member (publishes Sites, manages their own, duplicates member-visible Sites).
  • The personal account stays untouched: requests without a selector always target it.
  • Membership rides on normal oi sign-in (email code/link or Google) — no separate SSO setup.

Account selector#

API keys are personal. To act inside a workspace, attach a selector: the X-Oi-Account header (workspace subdomain or account UUID) on any endpoint that supports it, or the account body field on publish create. No selector means the personal account.

text

curl -sS https://oi.mrdk.io/api/v1/accounts \
  -H "Authorization: Bearer <API_KEY>"

GET /api/v1/accounts lists the personal account and every joined workspace — with subdomain, role, and a ready-to-use selector object — so agents discover valid selectors in one call rather than guessing at a subdomain. A selector that fails to resolve returns 404 account_not_found (UUID) or 409 account_selector_stale (subdomain — re-list and retry).

Create a workspace#

text

curl -sS https://oi.mrdk.io/api/v1/accounts \
  -H "Authorization: Bearer <API_KEY>" \
  -H "content-type: application/json" \
  -d '{ "displayName": "Acme", "subdomain": "acme" }'

One call creates the account, records the caller as admin, claims the subdomain, and provisions serving (wildcard DNS/SSL for label URLs). The response reports provisioning status; degraded includes a retryEndpoint, and publishing returns 409 workspace_not_ready until provisioning is active (normally under a couple of minutes).

Check name availability first with GET /api/v1/accounts/subdomain-availability?subdomain=acme (advisory only, not a reservation). Each user can have up to 3 live created workspaces; exceeding the cap returns 409 workspace_creation_limit (deleting one frees a slot).

Publish & label URLs#

Publishing into a workspace is the ordinary create → upload → finalize flow with a selector on create:

text

curl -sS https://oi.mrdk.io/api/v1/publish \
  -H "Authorization: Bearer <API_KEY>" \
  -H "content-type: application/json" \
  -d '{
    "account": "acme",
    "files": [
      { "path": "index.html", "size": 1234, "contentType": "text/html; charset=utf-8" }
    ]
  }'

Finalize assigns a label URL — {label}.acme.oi.mrdk.io, returned as accountUrl — beside the canonical {slug}.oi.mrdk.io URL, which keeps serving. With no workspaceLabel, a readable label is generated from the content; supply one to choose it (collisions auto-suffix). To insist on an exact name, send "workspaceLabelStrategy": "exact" — a collision then returns 409 workspace_label_taken instead of bumping.

Access#

Workspace Sites default to workspace members (account_members): visitors sign in and must hold a membership. The other audience is Public (anyone_with_link), optionally with a shared password (set via metadata; members get the password challenge like anyone else). Per-address restricted allowlists are personal-Site-only — the access API answers 409 workspace_access_mode_unsupported on workspace Sites.

Members, invites & auto-join#

Two ways in: an admin emails an invite (picking a role) and the recipient accepts with normal oi sign-in; or an admin sets an auto-join rule for the team's email domain, letting anyone with a matching address join themselves — one dashboard click, or automatically on first visiting a member-gated Site. Members can leave anytime; admins can remove members or change roles. The full surface:

  • GET/POST /api/v1/accounts/{accountId}/invites — list or send email invites (with role); DELETE .../invites/{inviteId} revokes. Invitees accept from the email or via the API below.
  • GET /api/v1/me/invites — the caller's pending invites, each with ready-to-call accept/decline endpoints (POST /api/v1/accounts/{accountId}/invites/{inviteId}/accept or .../decline).
  • GET /api/v1/accounts/{accountId}/members — list members; PATCH .../members/{userId} changes a role; DELETE .../members/{userId} removes a member (or yourself, to leave). A workspace always keeps at least one admin.
  • GET/POST /api/v1/accounts/{accountId}/domain-rules — admin-managed auto-join rules. Consumer email domains (gmail.com etc.) cannot become rules.
  • GET /api/v1/accounts?includeJoinable=1 — adds workspaces joinable right now because an active rule matches the caller's email domain; join with POST /api/v1/accounts/{accountId}/domain-rules/apply.

Managing workspace Sites#

Updates, metadata, access changes, and deletes on a workspace Site are open to the Site's original publisher and to admins (with the selector). Any active member can duplicate a member-visible Site into the same workspace via POST /api/v1/publish/{slug}/duplicate. Site Data on workspace Sites follows the same publisher-or-admin management rule.

Labels rename with PATCH /api/v1/accounts/{accountId}/site-labels/{label} (admins or the original publisher): the new URL serves immediately and the old label 307-redirects, keeping shared links alive.

Domains & Variables#

Workspaces support custom domains and variables at parity with personal accounts — the same /api/v1/domains and /api/v1/me/variables endpoints with the X-Oi-Account header. Admins add domains and manage variables; the original publisher or an admin connects a Site to a workspace domain. Variable values are write-only, and the workspace subdomain itself never takes domain mounts.

Rename & delete#

Admins can rename the display name (PATCH /api/v1/accounts/{accountId} with displayName; the subdomain is permanent), set an icon (iconEmoji, a single emoji or null), and delete the workspace (DELETE /api/v1/accounts/{accountId}) — deletion permanently removes its Sites, memberships, domains, and variables.

Workspace limits#

Workspaces are free. Each includes 500 Sites, 10 GB storage, 1 custom domain, and up to 50 members; each user can create up to 3 workspaces. These numbers may shift as we watch how teams actually use them — write to hi@oi.mrdk.io if you need more today.

Drives#

Drives are private cloud folders where agents keep files — documents, context, memory, plans, assets, media, research, code — share them with other agents, and pick work back up across sessions and tools.

  • A Drive is private storage, not a website — with one exception: POST /api/v1/publish/from-drive publishes every file in a Drive version to a public URL, with no way to scope it to part of the Drive. Read Publish from Drive before calling it, and keep the Drive a Site is published from separate from the Drive an agent keeps its own working files in.
  • Drives are personal-account only for now: workspaces have no shared Drives, and Drive requests ignore the account selector.
  • Drive ids look like drv_abc...; version ids like dv_abc...; token ids like dtok_abc....
  • Drive tokens start with drv_live_, are stored hashed, and are shown once at mint time.
  • Tokens are read-only or write, can scope to a pathPrefix, and can optionally manage narrower tokens.
  • Writes are staged to storage, finalized under ETag preconditions, and committed into a versioned manifest — every commit yields a new immutable Drive version, git-style, retained for the plan's history window.
  • Every signed-in account has a default Drive named My Drive; the default endpoint is idempotent and repairs missing defaults.

Drive helper#

Fresh skill installs bundle scripts/drive.sh, which wraps the Drive API: staged uploads, ETag handling, share blocks, folder import/export.

text

./scripts/drive.sh default
./scripts/drive.sh create "Research"
./scripts/drive.sh put Research notes/today.md --from ./notes/today.md
./scripts/drive.sh ls Research notes/
./scripts/drive.sh import My Drive agent-context/ --from ./notes --dry-run
./scripts/drive.sh export My Drive agent-context/ --to ./agent-context
./scripts/drive.sh share Research --perms write --prefix notes/ --ttl 7d
./scripts/drive.sh share Research --perms write --ttl 7d --manage-tokens --label "token manager"

Sharing Drives#

Handing an agent access to your own account? The account API key is usually simplest — put it in ~/.oi/credentials or pass --api-key, and the agent can use My Drive and any other owned Drive.

Reach for Drive tokens when access should be scoped rather than account-wide. A token binds to one Drive, is read-only or write, can expire, and can be fenced to a folder via pathPrefix — the right shape for someone else's agent, a temporary handoff, or your own agent that should touch only part of a Drive.

text

# Write access under notes/ only, for 7 days
./scripts/drive.sh share Research --perms write --prefix notes/ --ttl 7d --label "docs agent"

# Full-Drive read access
./scripts/drive.sh share Research --perms read --ttl 24h

# List and revoke tokens later
./scripts/drive.sh tokens Research
./scripts/drive.sh revoke Research dtok_...

drive.sh share prints a pasteable share block: a short explanation plus a structured oi_drive payload carrying api_base, the Drive id, the bearer token, permissions, scope, expiry, and optional pathPrefix. The whole block can be pasted into another agent with no further explanation of oi.

An agent receiving a share block should use the token as Authorization: Bearer <token>, stay inside pathPrefix when present, and preserve ETags on writes. A pathPrefix of null means the token spans the whole Drive.

Drive attribution#

Drive history attributes each change to the account key or token that made it. When per-agent attribution matters, mint one labeled token per agent or session — shared tokens produce shared attribution.

text

./scripts/drive.sh share Research --perms write --prefix notes/ --ttl 7d \
  --label "nightly summarizer"

File listings expose lastModifiedBy and lastOperation; the dashboard renders the same as "Last Edited By". A token minted with --label "nightly summarizer" shows that label on every file it creates or edits; unlabeled tokens fall back to the dtok_... id, and account writes show the account email. ETags exist for concurrency control only — they never identify an agent or account.

Drive API#

Drive routes take Authorization: Bearer <API_KEY> or a Drive token unless noted.

  • POST /api/v1/drives — create a Drive.
  • GET /api/v1/drives — list account Drives.
  • GET /api/v1/drives/default — the account default Drive, lazily creating My Drive when needed.
  • GET /api/v1/drives/:driveId/files?prefix=... — list files.
  • GET /api/v1/drives/:driveId/files/:path — read a file.
  • POST /api/v1/drives/:driveId/files/uploads — stage a write, receiving a presigned PUT URL.
  • POST /api/v1/drives/:driveId/files/finalize — finalize a staged upload.
  • PATCH /api/v1/drives/:driveId/files — apply a batch atomically against baseVersionId or per-op ETags.
  • POST /api/v1/drives/:driveId/files/move and DELETE /api/v1/drives/:driveId/files/:path — move or delete.
  • POST /api/v1/drives/:driveId/tokens — mint a scoped token, optionally with manageTokens; GET lists active tokens; DELETE revokes.

URL-encode each path segment on direct file read/delete routes (the helper does this itself). A delete whose path matches no file is a 404, not a silent no-op.

Preconditions: derived for one path, required for many. A call that touches a single path needs no precondition from you — staging records the path's current ETag (or its absence) and finalize commits against that, and a single-file DELETE pins the file as the request found it. Either way a concurrent writer produces 409 rather than a silent overwrite. Declare one yourself when you want a specific version: ifMatch/ifNoneMatch at staging, or an If-Match header on a delete; a declared precondition always wins, and a stale one still 409s.

A call that touches more than one path has no single path whose state could stand for it, so it asks you to name the version you believe you are changing: PATCH .../files needs a top-level baseVersionId or per-op ETags, and a folder delete needs both ?recursive=true and ?baseVersionId= — read the latter from headVersionId on GET /api/v1/drives/:driveId. Address the folder by its plain path (DELETE .../files/notes?recursive=true); a trailing slash is rejected as a malformed Drive path. Moves require ifMatch for the source and accept overwriteIfMatch when replacing a destination.

Token mints use perms (permissions accepted as an alias; sending both with different values is a 400 rather than a guess); unknown fields are rejected. The one-time live secret is returned as both token and secret, its id as tokenId, and its metadata — perms, manageTokens, pathPrefix, label, expiresAt — under tokenInfo. It is shown exactly once.

Publish from Drive#

Account owners can publish a Drive version server-side — no download/re-upload round trip. The route snapshots the chosen Drive version into a normal published site.

> This publishes the entire Drive version. Every file in it becomes public. > The request body has no path, prefix, or include/exclude field, so there is no > way to publish part of a Drive. Drafts, notes, research, scratch files, a > handoff document meant for another agent — anything in that version is served > at the Site's URL, at the same path it occupies in the Drive, to anyone who > has the URL. A Drive holding site/index.html beside notes/client-call.md > publishes both, and /notes/client-call.md is a working public link as soon > as the call returns. Nothing warns you: the call answers 200, because > publishing everything is what it was asked to do.

Keep a Drive per Site. The safe shape is one Drive that exists only to be published, and a different Drive for whatever an agent keeps for itself. Then "publish the whole Drive" is exactly what you meant, and it stays true as both Drives grow.

text

# A Drive that exists only to be published, and a separate one for working files.
curl -sS -X POST https://oi.mrdk.io/api/v1/drives \
  -H "Authorization: Bearer <API_KEY>" -H "content-type: application/json" \
  -d '{ "name": "q3-report-site" }'
# → { "drive": { "id": "drv_site...", "name": "q3-report-site", ... } }

curl -sS -X POST https://oi.mrdk.io/api/v1/drives \
  -H "Authorization: Bearer <API_KEY>" -H "content-type: application/json" \
  -d '{ "name": "agent-scratch" }'
# → { "drive": { "id": "drv_scratch...", ... } }

# Write only publishable files into drv_site, then publish it.
curl -sS -X POST https://oi.mrdk.io/api/v1/publish/from-drive \
  -H "Authorization: Bearer <API_KEY>" -H "content-type: application/json" \
  -d '{ "driveId": "drv_site..." }'

Stage only what should ship. If everything really does live in one Drive, do not publish that Drive: copy the shippable files into a publish Drive first, or use the ordinary POST /api/v1/publish flow with a files manifest naming exactly the paths you intend to serve — it is the only publish call in this API that lets you choose. Before publishing any Drive you did not create for the purpose, read its paths back and check every one of them:

text

curl -sS https://oi.mrdk.io/api/v1/drives/drv_.../files \
  -H "Authorization: Bearer <API_KEY>"
# Every path listed becomes a public URL under the Site.
text

./scripts/publish.sh --from-drive drv_... --slug my-site
./scripts/publish.sh --from-drive drv_... --version dv_... --client cursor

API route: POST /api/v1/publish/from-drive with { "driveId": "drv_...", "versionId": "dv_...", "slug": "..." }. Only driveId is required: versionId defaults to the Drive's head version, and slug names an existing Site this account owns to publish a new live version into. It does not reserve a name — a slug that is not already an owned Site answers 404. Omit it to mint a new Site, which is live immediately with no separate finalize. spaMode, password and viewer are also accepted; viewer takes the three documented keys (title, description, ogImagePath) and rejects anything else. None of these keys scope which files are taken — the whole version is published whatever you send.

A published-from-Drive Site is an ordinary Site: live immediately, and world-readable unless you gate it. password at publish time or PATCH /api/v1/publish/:slug/access afterwards closes it — but if you are reaching for either because the Drive held something private, publish a different Drive instead. Deleting the Site later does not un-fetch what was served in the meantime.

Drive share tokens cannot call this route. Publishing is a Site operation and needs an account API key, so a token handed to another agent to write into a Drive cannot turn that Drive into a website.

Request bodies#

Every JSON body this API accepts, in one place, so a call can be built from this page alone. Required keys must be present; anything under optional may be left out entirely. No body is a bare array or a bare string — each is a JSON object, and its top-level key is named here. Unknown keys are ignored on the Site routes and rejected on the Drive routes, which are strict, so spell them exactly.

callrequiredoptional
POST /api/v1/publish (create)files — array of 1–1000 file objectsttlSeconds (int ≥ 1 or null; ignored for anonymous Sites), viewer, spaMode (bool), displayName (1–80), displayDescription (1–280)
PUT /api/v1/publish/:slug (update)files; plus claimToken while the Site is anonymous and unclaimedas create
POST /api/v1/publish/:slug/finalizeversionId — from upload.versionId of the create or refresh that staged it
POST /api/v1/publish/:slug/uploads/refresh*(none with an owner's key)*; claimToken while the Site is anonymous and unclaimed — or send it as ?claimToken=
POST /api/v1/publish/:slug/claimclaimToken
PATCH /api/v1/publish/:slug/metadata*(none — send only what changes)*ttlSeconds (int ≥ 1 or null), viewer, password (1–128, null removes it), spaMode (bool or null), displayName, displayDescription
PATCH /api/v1/publish/:slug/accessmode"anyone_with_link" or "restricted"allowedEmails (≤200), allowedDomains (≤200), notify (bool — mail the allowed addresses as part of the call)
POST /api/v1/publish/:slug/access/invitesemails — 1–200 addresses already on the allowlist
POST /api/v1/publish/:slug/duplicate*(none)*viewer, access ("anyone_with_link" or "restricted", for the copy)
POST /api/v1/publish/from-drivedriveIdversionId, slug (an existing owned Site), spaMode, password, viewer — none of which scope which files are taken, see Publish from Drive
POST /api/v1/publishes/:slug/data/:collection*(the record's declared fields as a bare JSON object — no wrapper key)*
PATCH /api/v1/publishes/:slug/data/:collection/:recordId*(the fields to change, as a bare JSON object; JSON merge patch)*
POST /api/v1/drives*(none)*name (1–120), description (≤2000 or null), isDefault (bool)
PATCH /api/v1/drives/:driveId*(none)*name, description, isDefault
POST /api/v1/drives/:driveId/files/uploadspath, size (bytes)contentType, sha256 (64 hex, optional sha256: prefix), ifMatch (ETag), ifNoneMatch (only "*")
POST /api/v1/drives/:driveId/files/finalizeuploadIdpath
POST /api/v1/drives/:driveId/files/movefrom, to, ifMatchoverwriteIfMatch — the ETag at to you agree to replace
PATCH /api/v1/drives/:driveId/files (batch)ops — 1–50 operationsbaseVersionId (required unless every op carries its own precondition)
— one ops[] entryop"write", "create", "delete", "move"path, from, to, uploadId, ifMatch, ifNoneMatch, overwriteIfMatch, according to op
POST /api/v1/drives/:driveId/tokens*(none — the default is read on the whole Drive)*perms ("read"/"write"), pathPrefix (string, or null for the whole Drive), ttl ("7d", "24h", "30m", "60s", or seconds), label (≤200)
POST /api/auth/agent/request-codeemail
POST /api/auth/agent/verify-codeemail, code
POST /api/v1/me/keysname (1–64)

A files[] entry is {"path": "index.html", "size": 1234}: path is relative to the Site root with no parent-directory prefix, and size is the real byte length, which finalize verifies. contentType and hash (64-hex SHA-256 — an update skips re-uploading a file whose hash still matches) are optional.

A viewer object takes title (1–200), description (1–2000) and ogImagePath (a path that exists in the Site), all optional, and rejects every other key. It applies only to a Site with no index.html.

The contract also defines account (create, finalize) and workspaceLabel (finalize) for team workspaces. There are no workspaces on this deployment, so both are rejected rather than honored — see Availability.

Error responses#

Public API errors are JSON with a stable error field plus structured fields for recovery. Older clients can keep reading error; new agents should prefer code, message, retry_after, and docs_url when present.

text

{
  "error": "Rate limit exceeded. Max 60 anonymous sites per hour.",
  "code": "rate_limit_exceeded",
  "message": "Wait before retrying, or sign in for higher limits.",
  "retry_after": 3600,
  "docs_url": "https://oi.mrdk.io/docs#limits"
}
  • 400 invalid_request: fix the request body, parameters, file paths, or JSON syntax before retrying.
  • 401 unauthorized: provide Authorization: Bearer <API_KEY> or use the documented anonymous flow when supported.
  • 403 forbidden: the API key or Drive token does not have access to that resource.
  • 404 not_found: check the slug, Drive id, file path, endpoint path, or account ownership.
  • 409 conflict: complete or resolve the current resource state before retrying.
  • 410 gone: the resource expired or was deleted; create a new resource instead.
  • 429 rate_limit_exceeded: wait for retry_after seconds or follow the Retry-After header.
  • 503 storage_not_configured or service_unavailable: retry later or contact support if it persists.
  • 409 workspace_not_ready: the workspace is still provisioning; retry the provisioning endpoint, then publish again.
  • 409 workspace_label_taken: the exact label is in use; pick another or drop workspaceLabelStrategy.
  • 409 workspace_access_mode_unsupported: workspace Sites support members/Public/password, not restricted.
  • 409 workspace_creation_limit: per-user workspace cap reached; delete an unused workspace or contact support.
  • 409 account_selector_stale / 404 account_not_found: re-list /api/v1/accounts and retry with a current selector.

Some envelopes carry a bare https://oi.mrdk.io/docs with no #anchor. That is not a broken pointer: it means the failure is a request-shape problem rather than a resource-state problem, and the answer is a document rather than a section. 400 invalid_request names the offending field in details — go to Request bodies, which lists every body with its wrapper key, and check the top-level key first, since POST /api/v1/publish takes {"files": [ ... ]} and finalize takes {"versionId": "..."}. 404 "API route not found" means the path is not one this API defines; every path this deployment answers is listed under Availability and in /openapi.json. A state failure — 404 on a slug, 409, 410, 401, 429 — carries an anchor instead, landing on the section for that state.

Not found — a 404 on a slug or resource#

A 404 whose error names a slug or a resource id is not "this endpoint does not exist" — the endpoint exists and answered. Four states produce it, and they need different responses.

  • The slug is wrong. Slugs are adjective-noun-suffix and case-sensitive. Re-read slug from the create response rather than retyping it from a URL.
  • The Site was deleted. A deleted slug is tombstoned: the API answers 404 and the Site's own hostname answers 410. It is not coming back and the name is not reusable — publish a new Site.
  • The Site is anonymous and you are asking as an account. An unclaimed anonymous Site is invisible on every account surface — GET /api/v1/publish/:slug, the Site Data owner API, and from-drive's slug target all answer 404 for it, even while the Site is live and serving. This is deliberate: the account API must not confirm that another caller's anonymous slug exists. Claim it first, and the same call succeeds.
  • The resource belongs to another account. A version id, Drive id, or record id that exists but is not yours answers 404 rather than 403, so existence cannot be probed. Check which account's key you are sending.

A 404 is never worth retrying unchanged. Fix the identifier or the ownership, or accept that the resource is gone.

Conflicts — every 409 this deployment emits#

409 conflict means the request was well-formed and authorized, and the resource is in a state that cannot satisfy it. The error string always names the state. None are retryable as sent; all are fixable.

  • POST /api/v1/publish/:slug/uploads/refresh with no pending version. Refresh mints new presigned URLs *for a version that is already staged*. If the last publish was finalized, or the Site has only ever been live, there is nothing pending. Stage a version with PUT /api/v1/publish/:slug first, then refresh that. See Refresh upload URLs.
  • finalize on a version that is already live, or no longer pending. Finalize is not idempotent: a second call on the same versionId answers 409 Version … is already live. Read currentVersionId from the first response rather than retrying.
  • duplicate on a Site with no live version. A Site whose only version is pending has nothing to copy — finalize it first.
  • claim on an already-claimed Site. A second account cannot claim it; the claimToken is spent.
  • from-drive on a Drive with no committed version, or a version with no files. Write a file to the Drive and finalize it first.
  • A Drive write that lost a precondition race. Staging pins the path as it found it; if another writer changed it before your finalize, the finalize conflicts and nothing is lost. Re-read, re-stage, re-finalize. See Drive API.
  • Site Data on a manifest that does not compile. Every record operation conflicts until .oi/data.json is valid; the reason is in details, and there is no fallback to the previous rules. See the manifest.
  • Workspace conflicts — the workspace_* codes listed above.

Two near neighbours that are not 409s: finalize before every file is uploaded answers 400 invalid_request with the missing paths in details.missing (upload those, then finalize the same versionId again), and finalize with a versionId that names no version of this Site answers 404. Take versionId from upload.versionId in the response that staged the version.

Gone — 410#

410 gone means the resource existed and does not any more. It is the only 4xx in this API that is a statement about the past.

  • An anonymous Site past expiresAt. Anonymous Sites are deleted 24 hours after creation and an update does not extend that: PUT /api/v1/publish/:slug on an expired anonymous Site answers 410, as does claiming it. The bytes are gone — publish a new Site. To keep one, claim it before it expires.
  • A deleted Site's serving host. The API answers 404 for a deleted slug; the Site's hostname answers 410. Both are final.

Do not retry a 410. Create a new resource.

OpenAPI#

An OpenAPI 3.1 specification of the stable public API lives at /openapi.json. Agents can read it for request schemas, response schemas, authentication, and operation ids. It covers the public agent-facing routes — Sites, Drives, custom domains, subdomain handles, links, variables, support, and agent-assisted key creation — and intentionally leaves out admin and internal endpoints.

Agent discovery#

oi publishes well-known discovery files so agents can locate the product, docs, API spec, and skill install surfaces without scraping the homepage.

  • /.well-known/agent.json — product capabilities, auth model, docs, OpenAPI, and skill links.
  • /.well-known/agent-card.json — agent card describing oi skills and capabilities.
  • /.well-known/ai-plugin.json — OpenAI-style plugin manifest pointing to /openapi.json.
  • /.well-known/api-catalog — RFC 9727 API catalog/linkset.
  • /.well-known/skills/index.json — skill index: version, size, and SHA-256 of the hosted skill.
  • /.well-known/skills/oi — the hosted skill itself, the same document /skill.md serves.
  • /install.sh — installer script for the hosted skill (curl -fsSL https://oi.mrdk.io/install.sh | bash); verifies the checksum above before writing.
  • /pricing.md — machine-readable pricing tiers, features, and limits.
  • /schema-map.xml — schema map advertised from /robots.txt.
  • /schema-feeds/agent-resources.jsonl — JSONL structured-data feed for agent resources.

These files describe current public surfaces only. oi does not currently claim public MCP or OAuth support.

Limits#

Two tiers exist on this deployment and both are free. Nothing here is sold or metered, so no limit below is lifted by paying.

AnonymousAccount
Price$0$0
SitesTemporary Sites onlyNo limit enforced
Site expiry24 hoursPermanent, or a TTL you set
Storage quotaNo quota enforcedNo quota enforced
Max site file size25 MB25 MB
Max files per version10001000
Publish rate limit60 / hour / IP60 / hour / account
Version historyIncluded — list, restore, delete
Access controlIncluded — link, password, or invite-only
Site DataIncluded — no quota enforced
DrivesIncluded — no count or quota limit enforced
API keysUp to 50 named keys
Account neededNoYes (free — sign in at oi.mrdk.io/dashboard)

Both tiers are free; this deployment sells nothing and meters nothing. Site Data, Drives, custom domains, analytics, profiles, variables, and workspaces are not offered on any tier here — see Availability and /pricing.md.

The file-size and file-count limits apply to anonymous and authenticated publishing alike. There is no storage quota and no Site-count cap enforced on this deployment; if you reach a limit that is enforced, the API answers with an error naming it. See Availability for which API operations exist here and /pricing.md for the machine-readable version of this table.