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.
- 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.
| Method | Path | Summary | On this deployment |
|---|---|---|---|
| POST | /api/auth/agent/request-code | Request an email sign-in code | Available |
| POST | /api/auth/agent/verify-code | Verify an email code and receive an API key | Available |
| POST | /api/v1/publish | Create a Site | Available |
| GET | /api/v1/publishes | List account Sites | Available |
| GET | /api/v1/analytics | Get account analytics | Not implemented |
| GET | /api/v1/publishes/search | Search account Sites | Not implemented |
| GET | /api/v1/publishes/{slug}/analytics | Get Site analytics | Not implemented |
| GET | /api/v1/publishes/{slug}/data/{collection} | List Site Data records | Available |
| POST | /api/v1/publishes/{slug}/data/{collection} | Create a Site Data record | Available |
| GET | /api/v1/publishes/{slug}/data/{collection}/{recordId} | Get a Site Data record | Available |
| PATCH | /api/v1/publishes/{slug}/data/{collection}/{recordId} | Patch a Site Data record | Available |
| DELETE | /api/v1/publishes/{slug}/data/{collection}/{recordId} | Delete a Site Data record | Available |
| GET | /api/v1/profile | Get profile settings | Not implemented |
| PATCH | /api/v1/profile | Update profile settings | Not implemented |
| PATCH | /api/v1/profile/username | Change profile username | Not implemented |
| GET | /api/v1/profile/sites | List Sites on profile | Not implemented |
| POST | /api/v1/profile/sites | Add Site to profile | Not implemented |
| DELETE | /api/v1/profile/sites/{slug} | Remove Site from profile | Not implemented |
| GET | /api/v1/publish/{slug} | Get Site details | Available |
| PUT | /api/v1/publish/{slug} | Update an existing Site | Available |
| DELETE | /api/v1/publish/{slug} | Delete a Site | Available |
| POST | /api/v1/publish/{slug}/duplicate | Duplicate a Site | Available |
| POST | /api/v1/publish/{slug}/finalize | Finalize a pending Site version | Available |
| GET | /api/v1/publish/{slug}/versions | List a Site's published versions | Available |
| DELETE | /api/v1/publish/{slug}/versions/{versionId} | Permanently delete a recorded version | Available |
| POST | /api/v1/publish/{slug}/versions/{versionId}/restore | Restore a recorded version as the live Site | Available |
| POST | /api/v1/publish/{slug}/claim | Claim an anonymous Site | Available |
| POST | /api/v1/publish/{slug}/uploads/refresh | Refresh upload URLs for a pending Site version | Available |
| PATCH | /api/v1/publish/{slug}/metadata | Patch Site metadata and access controls | Available |
| GET | /api/v1/publish/{slug}/access | Read the Site access policy | Available |
| PATCH | /api/v1/publish/{slug}/access | Update Site access mode and allowlists | Available |
| POST | /api/v1/publish/{slug}/access/invites | Send invite emails for an existing access policy | Available |
| POST | /api/v1/publish/from-drive | Publish a Drive version as a Site | Available |
| GET | /api/v1/drives | List account Drives | Available |
| POST | /api/v1/drives | Create a Drive | Available |
| GET | /api/v1/drives/default | Get or create the default Drive | Available |
| GET | /api/v1/drives/{driveId} | Get Drive details | Available |
| PATCH | /api/v1/drives/{driveId} | Patch Drive metadata | Available |
| DELETE | /api/v1/drives/{driveId} | Soft-delete a Drive | Available |
| GET | /api/v1/drives/{driveId}/files | List Drive files | Available |
| PATCH | /api/v1/drives/{driveId}/files | Apply a batch of Drive file operations | Available |
| GET | /api/v1/drives/{driveId}/files/{path} | Read a Drive file | Available |
| DELETE | /api/v1/drives/{driveId}/files/{path} | Delete a Drive file or prefix | Available |
| POST | /api/v1/drives/{driveId}/files/uploads | Stage a Drive file write | Available |
| POST | /api/v1/drives/{driveId}/files/finalize | Finalize a staged Drive upload | Available |
| POST | /api/v1/drives/{driveId}/files/move | Move a Drive file | Available |
| GET | /api/v1/drives/{driveId}/tokens | List Drive tokens | Available |
| POST | /api/v1/drives/{driveId}/tokens | Create a scoped Drive token | Available |
| GET | /api/v1/domains | List custom domains | Unavailable here |
| POST | /api/v1/domains | Add a custom domain | Unavailable here |
| GET | /api/v1/domains/{domain} | Get custom domain status | Unavailable here |
| DELETE | /api/v1/domains/{domain} | Remove a custom domain | Unavailable here |
| GET | /api/v1/handle | Get account subdomain handle | Unavailable here |
| POST | /api/v1/handle | Create account subdomain handle (discontinued) | Unavailable here |
| DELETE | /api/v1/handle | Delete account subdomain handle | Unavailable here |
| GET | /api/v1/links | List subdomain handle or domain links | Unavailable here |
| POST | /api/v1/links | Create a link from a subdomain handle/domain path to a Site | Unavailable here |
| GET | /api/v1/links/{location} | Get a link | Unavailable here |
| PATCH | /api/v1/links/{location} | Update a link | Unavailable here |
| DELETE | /api/v1/links/{location} | Delete a link | Unavailable here |
| GET | /api/v1/accounts | List accounts available to the caller | Not implemented |
| POST | /api/v1/accounts | Create a workspace | Not implemented |
| GET | /api/v1/accounts/subdomain-availability | Check workspace subdomain availability | Not implemented |
| POST | /api/v1/accounts/{accountId}/provisioning | Provision a workspace subdomain | Not implemented |
| GET | /api/v1/accounts/{accountId}/site-labels | List workspace Site labels | Not implemented |
| POST | /api/v1/accounts/{accountId}/site-labels | Assign a workspace Site label | Not implemented |
| PATCH | /api/v1/accounts/{accountId}/site-labels/{label} | Rename a workspace Site label | Not implemented |
| PATCH | /api/v1/accounts/{accountId} | Rename a workspace or set its icon | Not implemented |
| DELETE | /api/v1/accounts/{accountId} | Delete a workspace | Not implemented |
| GET | /api/v1/accounts/{accountId}/members | List workspace members | Not implemented |
| POST | /api/v1/accounts/{accountId}/members | Add a workspace member directly | Not implemented |
| PATCH | /api/v1/accounts/{accountId}/members/{userId} | Change a member's role | Not implemented |
| DELETE | /api/v1/accounts/{accountId}/members/{userId} | Remove a member or leave the workspace | Not implemented |
| GET | /api/v1/accounts/{accountId}/invites | List pending workspace invites | Not implemented |
| POST | /api/v1/accounts/{accountId}/invites | Send a workspace invite | Not implemented |
| DELETE | /api/v1/accounts/{accountId}/invites/{inviteId} | Revoke a workspace invite | Not implemented |
| POST | /api/v1/accounts/{accountId}/invites/{inviteId}/accept | Accept a workspace invite | Not implemented |
| POST | /api/v1/accounts/{accountId}/invites/{inviteId}/decline | Decline a workspace invite | Not implemented |
| GET | /api/v1/me/invites | List the caller's pending workspace invites | Not implemented |
| GET | /api/v1/accounts/{accountId}/domain-rules | List auto-join domain rules | Not implemented |
| POST | /api/v1/accounts/{accountId}/domain-rules | Create an auto-join domain rule | Not implemented |
| DELETE | /api/v1/accounts/{accountId}/domain-rules/{ruleId} | Disable an auto-join domain rule | Not implemented |
| POST | /api/v1/accounts/{accountId}/domain-rules/apply | Join a workspace via a matching domain rule | Not implemented |
| GET | /api/v1/me/variables | List service variables | Not implemented |
| PUT | /api/v1/me/variables/{name} | Create or update a service variable | Not implemented |
| DELETE | /api/v1/me/variables/{name} | Delete a service variable | Not implemented |
| GET | /api/v1/me/keys | List API keys | Available |
| POST | /api/v1/me/keys | Create a named API key | Available |
| DELETE | /api/v1/me/keys/{id} | Revoke an API key | Available |
| POST | /api/v1/support | Send an authenticated support request | Not 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.
| Area | Operations | On this deployment |
|---|---|---|
| Auth | 5 | All available |
| Sites | 18 | 17 of 18 available |
| Analytics | 2 | None — not implemented here |
| Site Data | 5 | All available |
| Profiles | 6 | None — not implemented here |
| Drives | 15 | All available |
| Domains | 12 | None — unavailable on this deployment |
| Workspaces | 23 | None — not implemented here |
| Variables | 3 | None — not implemented here |
| Support | 1 | None — 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.
| Operation | Where | Kind | What happens here |
|---|---|---|---|
createSite | field ttlSeconds | conditional | Honored only on an authenticated create. An anonymous create ignores it and expires 24 hours after creation regardless. |
createSite | response expiresAt | omitted | Absent from an authenticated create response even when ttlSeconds set an expiry; the expiry is applied. Read it back from GET /api/v1/publish/{slug}. |
createSite | field account | rejected | There 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. |
listSites | response ownership / sharedSource / workspace | ignored | Present 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. |
listSiteDataRecords | precondition collection declared in .oi/data.json | required | The 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. |
listSiteDataRecords | precondition Site is account-owned | required | An 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. |
createSiteDataRecord | precondition collection declared in .oi/data.json | required | The 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. |
createSiteDataRecord | precondition Site is account-owned | required | An 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. |
getSiteDataRecord | precondition collection declared in .oi/data.json | required | The 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. |
getSiteDataRecord | precondition Site is account-owned | required | An 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. |
patchSiteDataRecord | precondition collection declared in .oi/data.json | required | The 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. |
patchSiteDataRecord | precondition Site is account-owned | required | An 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. |
deleteSiteDataRecord | precondition collection declared in .oi/data.json | required | The 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. |
deleteSiteDataRecord | precondition Site is account-owned | required | An 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. |
updateSite | field claimToken | required | The 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. |
updateSite | field ttlSeconds | conditional | Same rule as create: honored on an authenticated update, ignored on an anonymous one. An update never extends an anonymous Site's expiry either way. |
updateSite | field account | rejected | There are no workspaces here; a selector naming any other account answers 404 account_not_found. |
updateSite | response expiresAt / anonymous / warning | omitted | An 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. |
duplicateSite | response thumbnailJobId | ignored | Returned as a fresh UUID to keep the response shape; no thumbnail is queued, rendered, or served here. It identifies nothing. |
duplicateSite | field viewer | ignored | Only 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. |
duplicateSite | precondition source has a live version | required | A Site with only a pending version answers 409; finalize it before duplicating. |
duplicateSite | precondition access mode of the copy | derived | Duplicating 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. |
finalizeSiteVersion | response addedToProfile | ignored | Always 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. |
finalizeSiteVersion | response thumbnailJobId | ignored | Returned 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. |
finalizeSiteVersion | field workspaceLabel | ignored | Accepted 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. |
listSiteVersions | response previewUrl / thumbnailUrl | omitted | Never 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. |
refreshSiteUploadUrls | precondition authorization | derived | The 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. |
patchSiteAccess | field mode=account_members | rejected | Answers 409 workspace_access_mode_unsupported: the mode applies to workspace-owned Sites and there are none here. Use anyone_with_link or restricted. |
publishFromDrive | field slug | conditional | Names 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. |
publishFromDrive | field viewer | rejected | Only title, description and ogImagePath are accepted; any other key answers 400. Note the asymmetry with duplicateSite, which accepts and silently ignores extra viewer keys. |
applyDriveFileBatch | precondition baseVersionId or per-op ETags | required | The 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. |
readDriveFile | header Range | ignored | Drive 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. |
deleteDriveFile | precondition If-Match (single file) | derived | Optional, 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. |
deleteDriveFile | precondition recursive + baseVersionId (folder) | required | Deleting 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. |
createDriveFileUpload | precondition ifMatch / ifNoneMatch | derived | Both 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. |
finalizeDriveFileUpload | precondition staged precondition | derived | The 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. |
createDriveToken | field perms vs permissions | rejected | Both 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:
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:
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:
- Create the site:
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" }
]
}'
- Upload each file:
curl -X PUT "<upload.uploads[0].url>" \
-H "Content-Type: text/html; charset=utf-8" \
--data-binary @index.html
- Finalize:
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:
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:
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:
mkdir -p ~/.oi && echo "<API_KEY>" > ~/.oi/credentials && chmod 600 ~/.oi/credentials
The publish script resolves the key from these sources (first match wins):
--api-keyflag (CI/scripting only — avoid in interactive use)$OI_API_KEYenvironment variable~/.oi/credentialsfile (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
curl -sS https://oi.mrdk.io/api/v1/me/keys \
-H "Authorization: Bearer <API_KEY>" \
-H "content-type: application/json" \
-d '{"name": "claude"}'
Response:
{
"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
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
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:
{
"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 likemy-project/index.html.hash(optional): SHA-256 hex digest, 64 lowercase chars. On update, files whose hash matches the previous version are dropped fromupload.uploads[]and listed inupload.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 noindex.html).
Response (authenticated):
{
"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:
{
"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:
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)
{ "versionId": "01K..." }
Owned sites finalize with Authorization: Bearer; anonymous sites finalize without auth.
Response:
{
"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
claimTokenin 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:
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:
{
"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>.
{ "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:
{
"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:
{
"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>.
{
"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.
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.
{
"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.
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_linkorrestricted. Passwords go through metadata, not this endpoint.allowedEmails/allowedDomains: only used withrestricted; 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: truesends real invite emails to newly added exact-email recipients — confirm with the user first. Domain rules never trigger invite emails.- When
notify: truesends or skips emails, the PATCH response carries anotificationsobject, 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:
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:
# 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:
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:
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:
{ "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.
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:
| Request | What it does |
|---|---|
GET /.oi/access | the unlock page for whichever mode the site is in |
POST /.oi/access/password | password mode: submit the password |
POST /.oi/access/request-code | restricted mode, step 1: ask for a one-time code |
POST /.oi/access/verify-code | restricted 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.
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.
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#
index.htmlat the root serves at/.- A site with exactly one file serves an auto-viewer (rich viewer for images, PDF, video, audio; a download page otherwise).
- Failing that, the first
index.htmlfound in any subdirectory serves at/. - Otherwise an auto-generated directory listing serves: folders clickable, images as a gallery, other files listed with sizes. No
index.htmlrequired.
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 domainGET /api/v1/domains— list your domainsGET /api/v1/domains/:domain— check statusDELETE /api/v1/domains/:domain— remove a domain
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 isfallback.oi.mrdk.io. - Apex domains (
example.com): create the records fromdns_instructions— host@(some providers use a blank field), typically two A records plus awwwCNAME tofallback.oi.mrdk.io. Visits towww.example.comredirect toexample.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/handleDELETE /api/v1/handle
Handles cannot be renamed. Deleting one removes the namespace and its links.
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/linksGET /api/v1/linksGET /api/v1/links/:locationPATCH /api/v1/links/:locationDELETE /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:
{
"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:
{
"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#
- Create
.oi/data.jsonat the root of the directory being published. - Declare each collection with its fields, access rules, and optional rate limit.
- Publish (new Site or update) with
./scripts/publish.sh ./site-diror the publish API. - From browser JavaScript, call
./.oi/data/:collectionrelative to the published page. - 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.
// .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.
| type | accepts | its own keys |
|---|---|---|
string | any JSON string | minLength, maxLength, trim |
number | any finite number | minimum, maximum |
integer | a finite whole number | minimum, maximum |
boolean | true / false only | — |
url | an absolute URL | maxLength, allowedProtocols (default ["http","https"]), trim |
email | local@domain.tld | maxLength, trim |
datetime | ISO 8601, stored normalized to UTC | — |
array | a JSON array (items untyped) | minItems, maxItems |
object | a JSON object | maxProperties |
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.
// 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:
// 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).
# 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:
// .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.
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.
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.
{
"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.
{
"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:
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:
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.
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:
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):
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:
curl -sS https://oi.mrdk.io/api/v1/profile/sites \
-H "Authorization: Bearer <API_KEY>"
Add an owned Site:
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:
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:
{
"error": "That username is taken.",
"code": "username_unavailable",
"message": "Pick a different username and retry."
}
The codes an agent should branch on:
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>.
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.
// .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.
{
"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.
{
"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.
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.
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#
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:
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}/acceptor.../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 withPOST /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-drivepublishes 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 likedv_abc...; token ids likedtok_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.
./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.
# 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.
./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 creatingMy Drivewhen 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 againstbaseVersionIdor per-op ETags.POST /api/v1/drives/:driveId/files/moveandDELETE /api/v1/drives/:driveId/files/:path— move or delete.POST /api/v1/drives/:driveId/tokens— mint a scoped token, optionally withmanageTokens; 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.
# 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:
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.
./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.
| call | required | optional |
|---|---|---|
POST /api/v1/publish (create) | files — array of 1–1000 file objects | ttlSeconds (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 unclaimed | as create |
POST /api/v1/publish/:slug/finalize | versionId — 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/claim | claimToken | — |
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/access | mode — "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/invites | emails — 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-drive | driveId | versionId, 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/uploads | path, size (bytes) | contentType, sha256 (64 hex, optional sha256: prefix), ifMatch (ETag), ifNoneMatch (only "*") |
POST /api/v1/drives/:driveId/files/finalize | uploadId | path |
POST /api/v1/drives/:driveId/files/move | from, to, ifMatch | overwriteIfMatch — the ETag at to you agree to replace |
PATCH /api/v1/drives/:driveId/files (batch) | ops — 1–50 operations | baseVersionId (required unless every op carries its own precondition) |
— one ops[] entry | op — "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-code | email | — |
POST /api/auth/agent/verify-code | email, code | — |
POST /api/v1/me/keys | name (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.
{
"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: provideAuthorization: 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 forretry_afterseconds or follow the Retry-After header. - 503
storage_not_configuredorservice_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 dropworkspaceLabelStrategy. - 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/ 404account_not_found: re-list/api/v1/accountsand 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-suffixand case-sensitive. Re-readslugfrom the create response rather than retyping it from a URL. - The Site was deleted. A deleted slug is tombstoned: the API answers
404and the Site's own hostname answers410. 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, andfrom-drive'sslugtarget all answer404for 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
404rather than403, 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/refreshwith 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 withPUT /api/v1/publish/:slugfirst, then refresh that. See Refresh upload URLs.finalizeon a version that is already live, or no longer pending. Finalize is not idempotent: a second call on the sameversionIdanswers409 Version … is already live.ReadcurrentVersionIdfrom the first response rather than retrying.duplicateon a Site with no live version. A Site whose only version is pending has nothing to copy — finalize it first.claimon an already-claimed Site. A second account cannot claim it; the claimToken is spent.from-driveon 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.jsonis valid; the reason is indetails, 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/:slugon an expired anonymous Site answers410, 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
404for a deleted slug; the Site's hostname answers410. 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.mdserves./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.
| Anonymous | Account | |
|---|---|---|
| Price | $0 | $0 |
| Sites | Temporary Sites only | No limit enforced |
| Site expiry | 24 hours | Permanent, or a TTL you set |
| Storage quota | No quota enforced | No quota enforced |
| Max site file size | 25 MB | 25 MB |
| Max files per version | 1000 | 1000 |
| Publish rate limit | 60 / hour / IP | 60 / hour / account |
| Version history | — | Included — list, restore, delete |
| Access control | — | Included — link, password, or invite-only |
| Site Data | — | Included — no quota enforced |
| Drives | — | Included — no count or quota limit enforced |
| API keys | — | Up to 50 named keys |
| Account needed | No | Yes (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.