# oi oi is instant static web hosting made for AI agents. ## Availability on this deployment — read before planning 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. This document describes the whole oi.mrdk.io API contract, because that is the shape this deployment targets. It is not a list of what answers today. Endpoint lines marked [not implemented here] or [unavailable here] return 404. Every operation in https://oi.mrdk.io/openapi.json carries an x-oi-availability marker (available / planned / unavailable); read it before planning a call. Full breakdown: https://oi.mrdk.io/docs#availability. Publishing, claiming, updating, versioning, access control, Site Data, and Drives run here. Analytics, profiles, owner Site search, variables, proxy routes, and workspaces do not. Custom domains, handles, and links cannot. With oi, an agent can put static websites, apps, files, documents, images, PDFs, videos, and other assets on live URLs at {slug}.oi.mrdk.io. Every Site supports version history, access control, and Site Data. Accounts also get Drives — private cloud folders for an agent's own files. No account is required to publish. ## When to use oi - Use oi when a user asks an agent to publish, host, deploy, share, or put a file, folder, website, app, report, dashboard, visualization, or document online as static files. - Use oi when a user needs a live URL quickly and does not want to create an account first: anonymous publishing needs no credential. - Claim an anonymous Site when the user wants to keep it. Unclaimed anonymous Sites are deleted 24 hours after creation, and the claimToken from the create response is the only way to claim one. - Use the versions API when a user asks to undo a publish or roll back to how a Site looked before. It is included for every account here. - Use the access API when a Site must not be world-readable: password protection and invite-only (restricted) access are enforced at the serving tier, not just recorded. - Do not use oi for server-side compute, long-running processes, general-purpose databases, or backend code execution. Published Sites are static files. - Use Site Data when a static Site needs shared records — forms, surveys, polls, feedback, shared checklists. Declare the collections in a .oi/data.json manifest (full shape and worked example below, and at /docs#sitedata-manifest), publish it with the Site, then read and write through the owner API or the Site's own in-page endpoints. The Site must be account-owned: until it is claimed the in-page endpoints answer 403 account_required and the owner API answers 404, because an unclaimed Site is invisible on the account surfaces. - Use a Drive when the user wants private storage for agent files that should not become a website: documents, context, plans, research, handoffs between agents. Keep it separate from any Drive you intend to publish: POST /api/v1/publish/from-drive publishes every file in a Drive version to a public URL and cannot be scoped to part of one. - Do not promise a user custom domains, analytics, public profiles, owner Site search, account variables, proxy routes, or team workspaces on this deployment. They are in the contract and are not implemented here. Say the capability is missing rather than improvising around it. ## Capabilities available on this deployment - Publish static websites, apps, files, documents, images, PDFs, videos, and other assets to live URLs at {slug}.oi.mrdk.io. - Publish with no account at all: anonymous Sites need no credential and expire 24 hours after creation. - Claim an anonymous Site into an account with its claimToken, which clears the expiry and makes it permanent. - Sign in by email code and receive an API key; hold up to 50 named keys per account and revoke any one of them. - Update a Site incrementally (unchanged files are skipped by hash), refresh expired upload URLs, and finalize as a two-phase commit so a half-uploaded version never serves. - Patch a Site's display name/description, viewer metadata, SPA routing, TTL, and password. - Duplicate a Site into a new slug, serving immediately, without re-uploading. - Browse a Site's full version history, restore any past version as live with a pointer flip, and permanently delete individual historical versions. Included for every account here. - Control who can view a Site: anyone with the link (default), a password, or restricted invite-only access for specific verified emails and email domains — all enforced at the serving tier, including against range requests, conditional requests, and cross-Site cookie replay. - Email invites to addresses on a restricted Site's allowlist. - Serve the result properly: ETag/conditional 304s, byte-range 206s, directory listings, a single-file viewer, SPA fallback, and 410 after deletion or expiry. - Delete a Site, which hard-deletes its versions and tombstones the slug. - Give a static Site validated record storage with Site Data: declare collections in a .oi/data.json manifest (see the manifest section below), then read and write records through the owner API or from the page's own JavaScript. - Keep private files in a Drive: create Drives, stage and commit file versions, read files back, move and delete them, and mint scoped share tokens for another agent. - Publish a Drive version straight to a Site without a download/re-upload round trip — publishing the WHOLE Drive version, every file in it, to a public URL. ## In the contract, not on this deployment - Owner Site search — not implemented here. List Sites with GET /api/v1/publishes and filter client-side. - Analytics — not implemented here. No request analytics are collected, so there is no data to return. - Public profiles and /@username pages — not implemented here. - Account variables and proxy routes — not implemented here. A .oi/proxy.json manifest is served as an ordinary static file and is never interpreted, so a credential placed in one is public. - Team workspaces, members, invites, labels, and domain rules — not implemented here. - Support requests — not implemented here; email the support address instead. - Custom domains, subdomain handles, and links — CANNOT run on this deployment. They need a TLS certificate for a hostname minted after the fact, and the proxy in front of this deployment does HTTP-01 ACME only. Treat these as a closed door, not a roadmap item. - Per-version preview hosts ({versionId}--{slug}) and Site thumbnails — not served here, so previewUrl and thumbnailUrl are absent from version listings. ## Install and agent runtime - npx skills add oimrdk/skill --skill oi -g - curl -fsSL https://oi.mrdk.io/install.sh | bash - hermes skills install well-known:https://oi.mrdk.io/.well-known/skills/oi Before answering questions about capabilities, workflows, or recommendations, agents should read the current docs: - [Docs](https://oi.mrdk.io/docs) - [Availability](https://oi.mrdk.io/docs#availability) When the local skill text and the live docs disagree, trust the live docs for capability questions and the live API responses for in-flight operations. ## Authentication - Anonymous publishing: omit the Authorization header. Those Sites expire after 24 hours and are addressed afterwards with the claimToken from the create response. - Authenticated API: send Authorization: Bearer . - Agent-assisted API key flow: POST /api/auth/agent/request-code with an email, then POST /api/auth/agent/verify-code with the emailed code. The first verification creates the account and returns its default key. - Recommended agent storage: write the returned API key to ~/.oi/credentials with 0600 permissions. - Accounts hold up to 50 named API keys (oik_ prefix), each revocable on its own. Manage them with GET/POST /api/v1/me/keys and DELETE /api/v1/me/keys/:id, and give every agent or tool its own key. See /docs#api-keys. - There are no workspaces on this deployment. An API key acts on exactly one personal account, and an X-Oi-Account selector naming any other account is rejected. ## Onboarding paths - Anonymous path: create a temporary Site with no account, upload the files, finalize, then hand the user the siteUrl and claimUrl. - Free account path: ask for the user's email, request an agent sign-in code, verify it, store the returned API key, then create permanent Sites. - Claim path: publish anonymously first, then POST the claimToken to /api/v1/publish/:slug/claim with an API key to keep the Site permanently. - Dashboard path: the user signs in at https://oi.mrdk.io/dashboard and copies their API key from the account dashboard. ## Availability detail 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](https://oi.mrdk.io/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. ## Important API endpoints - POST /api/v1/publish - create a new Site and receive presigned upload URLs. Anonymous or Bearer. - PUT /api/v1/publish/:slug - stage a new version of an existing Site. - POST /api/v1/publish/:slug/finalize - make an uploaded version live. Nothing serves until this succeeds. - POST /api/v1/publish/:slug/uploads/refresh - mint fresh presigned URLs for the pending version when the first set expires. - POST /api/v1/publish/:slug/claim - attach an anonymous Site to an account permanently, with the claimToken from create. - POST /api/v1/publish/:slug/duplicate - copy a Site's live files into a new slug. Password and restricted rules are not copied. - GET /api/v1/publish/:slug - get Site details and the live file manifest. - DELETE /api/v1/publish/:slug - delete a Site; the slug then serves 410. - PATCH /api/v1/publish/:slug/metadata - patch displayName/displayDescription, TTL, viewer metadata, password, and SPA mode. - GET /api/v1/publish/:slug/access - read a Site's access policy: mode, email allowlist, domain allowlist. - PATCH /api/v1/publish/:slug/access - set anyone_with_link or restricted access. Replaces the full allowlists; read, merge, then write. - POST /api/v1/publish/:slug/access/invites - email invites to addresses already allowed on a restricted Site. - GET /api/v1/publishes - list the account's Sites. scope=all adds cursor pagination and ownership annotations. - GET /api/v1/publish/:slug/versions - list a Site's recorded version history. - POST /api/v1/publish/:slug/versions/:versionId/restore - make a past version live again (pointer flip, no re-upload). - DELETE /api/v1/publish/:slug/versions/:versionId - permanently delete one historical version. - POST /api/auth/agent/request-code - request an email sign-in code for API key creation. - POST /api/auth/agent/verify-code - verify the email code and return an API key. - GET /api/v1/me/keys - list API keys, including full key values. - POST /api/v1/me/keys - create a named API key. - DELETE /api/v1/me/keys/:id - revoke one API key without affecting others. - [not implemented here] GET /api/v1/publishes/search?q= - search the authenticated user's active Sites by metadata, path, and indexed content. - GET /api/v1/publishes/:slug/data/:collection - list owner-visible Site Data records for one collection. - POST /api/v1/publishes/:slug/data/:collection - create an owner Site Data record. - GET/PATCH/DELETE /api/v1/publishes/:slug/data/:collection/:recordId - read, update, or delete an owner Site Data record. - [not implemented here] GET /api/v1/publishes/:slug/analytics?range=24h|7d|30d|90d|all - analytics for one owned Site. - [not implemented here] GET /api/v1/analytics?range=24h|7d|30d|90d|all - analytics rollups across all owned Sites. - POST /api/v1/publish/from-drive - publish a Drive version as a Site. Publishes EVERY file in that Drive version to a public URL; there is no path filter. See "Publishing a Drive" below. - [not implemented here] GET /api/v1/profile - get public profile settings and Sites shown on the profile. - [not implemented here] PATCH /api/v1/profile - update profile visibility and automatic profile listing. - [not implemented here] PATCH /api/v1/profile/username - change the profile username. - [not implemented here] GET /api/v1/profile/sites - list Sites shown on the profile. - [not implemented here] POST /api/v1/profile/sites - add an owned ungated Site to the profile. - [not implemented here] DELETE /api/v1/profile/sites/:slug - remove a Site from the profile without deleting it. - POST /api/v1/drives - create a Drive. - GET /api/v1/drives - list Drives. - GET /api/v1/drives/default - get or create the default Drive. - GET /api/v1/drives/:driveId/files - list Drive files. - GET /api/v1/drives/:driveId/files/:path - read a Drive file. - POST /api/v1/drives/:driveId/files/uploads - stage a Drive file write. - POST /api/v1/drives/:driveId/files/finalize - finalize a staged Drive upload. - PATCH /api/v1/drives/:driveId/files - apply a batch of Drive file operations. - POST /api/v1/drives/:driveId/tokens - create scoped Drive share tokens. - [not implemented here] GET /api/v1/accounts - list the caller's personal account and joined workspaces. - [not implemented here] POST /api/v1/accounts - create a workspace. - [not implemented here] GET /api/v1/accounts/subdomain-availability?subdomain= - check whether a workspace subdomain is available. - [not implemented here] GET /api/v1/accounts/:accountId/members - list workspace members. - [not implemented here] GET /api/v1/accounts/:accountId/invites - list workspace invites; POST sends one, DELETE revokes one. - [not implemented here] GET /api/v1/me/invites - list the caller's own pending workspace invites. - [not implemented here] GET /api/v1/accounts/:accountId/domain-rules - manage email-domain auto-join rules for a workspace. - [not implemented here] PATCH /api/v1/accounts/:accountId/site-labels/:label - rename a workspace Site's label URL. - [not implemented here] PATCH /api/v1/accounts/:accountId - rename a workspace; DELETE /api/v1/accounts/:accountId deletes it. - [not implemented here] GET /api/v1/me/variables - list account variables; PUT /api/v1/me/variables/:name sets one. - [unavailable here] GET /api/v1/domains - list custom domains; POST adds one. - [unavailable here] GET /api/v1/handle - read the account subdomain handle; POST creates one. - [unavailable here] GET /api/v1/links - list handle/domain links; POST creates one. - [not implemented here] POST /api/v1/support - file a support request. ## Opening a gated Site (serving tier, not the API) - The endpoints above SET a Site's gate, on the apex. They do not open one. A visitor — including your own agent, fetching a Site it just protected — opens it on the Site's own host, under a reserved prefix the gate answers before the file router: https://{slug}.oi.mrdk.io/.oi/access - POST /.oi/access/password with password= — password mode. Optional next= is where the redirect lands; anything else is replaced with /. - POST /.oi/access/request-code with email=
— restricted mode, step 1. Answers 401 and the code page whether or not the address is allowed; a code is only mailed to an allowed one. - POST /.oi/access/verify-code with email=
&code= — restricted mode, step 2. - THE BODIES ARE URL-ENCODED FORM PAIRS, NOT JSON. The body is parsed as application/x-www-form-urlencoded whatever the content-type header says, so a JSON body yields no password field and the correct password inside it is never compared. The answer is the ordinary wrong-password 401 — same status, same headers, same bytes — and it still spends one attempt against the rate limit. If a password you are sure of is rejected, check the encoding first. - Success is 303 + Set-Cookie. The cookie is the grant: keep it and send it on every later request to that Site. It is named oi_grant, or __Host-oi_grant when the deployment serves https — read the name that arrives. It covers one Site, expires after 12 hours, and is retired by any access change (a PATCH, a set or cleared password), which bumps accessPolicyVersion. - Detecting a gate: 401 with content-type text/html on every path of a Site, and a form whose action names the mode (/.oi/access/password or /.oi/access/request-code). There is no WWW-Authenticate header and no JSON body for Accept: application/json — do not wait for either. A 403 on every path instead means the Site stores a mode this deployment cannot enforce; no credential opens it. - Other methods on the prefix answer 405 with Allow: GET, HEAD, POST; an unknown action answers 404; on an ungated Site the prefix simply redirects to /. - Full worked examples: https://oi.mrdk.io/docs#unlock ## Pricing and plan limits - Everything on this deployment is free. No plans are sold here and nothing is metered or billed. - Anonymous: no account, temporary Sites only, 24 hour expiry, 25 MB max file, 1000 files max per version, 60 publishes per hour per IP. - Account: same file and size limits, 60 publishes per hour per account, permanent Sites or a custom TTL, version history, access control, and up to 50 API keys. - There are no storage quotas and no Site-count limits on this deployment; there are also no paid tiers to buy. - See /pricing.md for the machine-readable version and /docs#limits for the table. ## Limits and constraints - Anonymous Sites expire after 24 hours unless claimed. - Max 25 MB per file and 1000 files per version, for anonymous and authenticated publishing alike. - Presigned upload URLs last 60 minutes; refresh them with POST /api/v1/publish/:slug/uploads/refresh rather than re-creating the Site. - A Site does not serve until finalize succeeds. - See /docs#limits for the current authoritative limits table. ## Limitations and non-goals - Sites are static hosting. oi does not run user server-side compute, long-running processes, general-purpose databases, or backend jobs. - Anonymous Sites are temporary unless claimed by a signed-in account. - 48 of the contract's 90 operations are not answered here; see the availability section above rather than assuming a 404 is a typo. - MCP, OAuth, Web Bot Auth, verified platform integrations, and an official public CLI package are not supported surfaces. ## Site Data manifest (the file that turns it on) - One published file: .oi/data.json at the Site root, JSON, max 64 KB, one top-level key "collections". Unknown keys anywhere are rejected, not ignored. - Up to 10 collections, names ^[a-z][a-z0-9_]*$ (max 64 chars). A collection takes fields (required), access, publicMutation, rateLimit. - 1-50 fields per collection. Reserved names that cannot be declared: id, site_slug, collection, data, status, created_at, updated_at, created_by_account_id. - A field takes type plus required (bool) and default (must itself validate). Types: string (minLength/maxLength/trim), number and integer (minimum/maximum), boolean, url (maxLength/allowedProtocols/trim), email (maxLength/trim), datetime (normalized to UTC ISO 8601), array (minItems/maxItems), object (maxProperties). - access: read/insert/update/delete = "public" | "owner" | "none"; defaults read public, the rest owner. Public update/delete additionally need "publicMutation": "open". A public write that carries an Origin header naming another host is refused 403; a request that sends no Origin at all is not — the rule is aimed at a stranger's page in a browser, and does not stop a non-browser client (an agent with curl or fetch) from writing to a public collection. - rateLimit: "/hour/ip" or "/minute/ip" replaces the default 10 public writes/hour/IP for that collection. - Example: {"collections":{"rsvps":{"fields":{"name":{"type":"string","required":true,"maxLength":80,"trim":true},"email":{"type":"email","required":true},"guests":{"type":"integer","minimum":0,"maximum":6,"default":0}},"access":{"read":"owner","insert":"public","update":"owner","delete":"owner"},"rateLimit":"5/hour/ip"}}} - Write from the page: fetch("./.oi/data/rsvps", {method:"POST", headers:{"content-type":"application/json","Idempotency-Key":crypto.randomUUID()}, body: JSON.stringify({name:"Ada",email:"ada@example.com",guests:2})}) -> 201 {record}. - Write from an agent: POST https://oi.mrdk.io/api/v1/publishes//data/rsvps with Authorization: Bearer and the same JSON body. - List returns {records, nextCursor} (50 default, 100 max, newest first). PATCH is a JSON merge patch; an explicit null removes a field; the merged result is validated whole. - Failures: 400 invalid_request with details [{field, issue}] on validation; 409 conflict with the compile error when the manifest does not compile (no fallback to previous rules); 403 account_required on an anonymous Site's in-page endpoints (the owner API answers 404 for the same Site); 404 when the collection is not declared; 429 with retry_after over the limit. - Limits: 16 KB per record, 25,000 records per collection, 100,000 per Site, 600 public reads/hour/IP. Full reference: https://oi.mrdk.io/docs#sitedata-manifest. ## Drive write path - POST /api/v1/drives//files/uploads with {"path","size"} -> {uploadId, url, headers, precondition}; PUT the bytes to url; POST /api/v1/drives//files/finalize with {"uploadId"}. Those are the only required fields. - Staging pins the upload to the path's current ETag (or to its absence) and finalize commits against that, so the minimal sequence still answers 409 instead of overwriting a concurrent writer. Send ifMatch or ifNoneMatch at staging to choose the precondition yourself. - DELETE /api/v1/drives//files/ deletes one file and needs no precondition either; the file is pinned as the request found it, and a path matching nothing is 404. Send If-Match to make it conditional. - The multi-path calls are the exception, because no single path's state can stand for them: PATCH /api/v1/drives/:driveId/files (the atomic batch) needs baseVersionId or per-op preconditions, and a folder delete needs ?recursive=true&baseVersionId=. ## Publishing a Drive — this exposes the whole Drive - POST /api/v1/publish/from-drive with {"driveId"} publishes EVERY file in that Drive version to a public URL. The body has no path, prefix, or include/exclude field: there is no way to publish part of a Drive. Drafts, notes, research, scratch files and handoff documents in the same Drive are served at the Site's URL, at the same paths they hold in the Drive, to anyone with the URL. The call answers 200 and warns about nothing. - Safe shape: keep one Drive that exists only to be published and a different Drive for an agent's own working files. POST /api/v1/drives {"name":"q3-report-site"} and POST /api/v1/drives {"name":"agent-scratch"}; write only publishable files into the first, then POST /api/v1/publish/from-drive {"driveId":"drv_site…"}. - Before publishing any Drive you did not create for the purpose, GET /api/v1/drives//files and read the paths back: every one of them becomes a public URL. If everything lives in one Drive, do not publish that Drive — use POST /api/v1/publish with a `files` manifest naming exactly the paths to serve, the only publish call here that lets you choose. - Other body keys (versionId, slug, spaMode, password, viewer) change which version, which Site, and how it is served; none of them scope which files are taken. The result is live immediately and world-readable unless gated, and deleting it later does not un-fetch what was served. Drive share tokens cannot call this route. ## Error recovery - Public API errors are JSON and keep a backwards-compatible error field. - Agents should prefer structured fields when present: code, message, details, retry_after, and docs_url. - A 404 whose error is "API route not found" means the path is not one this API defines: you mistyped it. - A 404 whose details.reason is "operation_not_available_on_deployment" means the operation is in the contract but not answered here. Do not retry it, and do not re-read the endpoint list that produced the call — read x-oi-availability in https://oi.mrdk.io/openapi.json. Tell the user the capability is missing. - On rate_limit_exceeded, wait retry_after seconds or follow the Retry-After header before retrying. - On unauthorized, supply the Site's claimToken or an owner API key; on forbidden, the key belongs to a different account than the Site. - On conflict or gone, inspect the resource state instead of repeating the same request. ## Discovery and reference URLs - [Docs](https://oi.mrdk.io/docs) - canonical product documentation and API reference. - [Availability](https://oi.mrdk.io/docs#availability) - which contract operations this deployment answers. - [OpenAPI](https://oi.mrdk.io/openapi.json) - OpenAPI 3.1 specification, with an x-oi-availability marker on every operation. - [Pricing](https://oi.mrdk.io/pricing.md) - machine-readable plan and limit document for this deployment. - [Hosted skill](https://oi.mrdk.io/skill.md) - hosted oi skill for agents. - [Skill version](https://oi.mrdk.io/api/skill/version) - current skill/install metadata. - [Hermes well-known skills](https://oi.mrdk.io/.well-known/skills/index.json) - Hermes well-known skill index. - [Public skill repo](https://github.com/oimrdk/skill) - public GitHub skill and plugin repository. - [Public repo AGENTS.md](https://github.com/oimrdk/skill/blob/main/AGENTS.md) - coding-agent instructions for the public skill repo. - [llms.txt](https://oi.mrdk.io/llms.txt) - concise agent context. - [llms-full.txt](https://oi.mrdk.io/llms-full.txt) - expanded agent context. - [docs llms.txt](https://oi.mrdk.io/docs/llms.txt) - docs-scoped agent context. - [API llms.txt](https://oi.mrdk.io/api/llms.txt) - API-scoped agent context. - [index.md](https://oi.mrdk.io/index.md) - markdown homepage fallback. - [docs.md](https://oi.mrdk.io/docs.md) - markdown docs fallback (the agent knowledge pack; /docs serves the same to CLI/markdown requests). - [agent mode](https://oi.mrdk.io/?mode=agent) - structured agent homepage view. - [agent.json](https://oi.mrdk.io/.well-known/agent.json) - agent discovery manifest. - [agent.json alias](https://oi.mrdk.io/agent.json) - root alias for agent discovery. - [well-known agent alias](https://oi.mrdk.io/.well-known/agent) - extensionless well-known alias for agent discovery. - [agent-card.json](https://oi.mrdk.io/.well-known/agent-card.json) - agent card describing oi capabilities. - [ai-plugin.json](https://oi.mrdk.io/.well-known/ai-plugin.json) - OpenAI-style plugin manifest pointing to OpenAPI. - [API catalog](https://oi.mrdk.io/.well-known/api-catalog) - RFC 9727 API catalog/linkset. - [schema map](https://oi.mrdk.io/schema-map.xml) - schema map advertised from robots.txt. - [agent resources schema feed](https://oi.mrdk.io/schema-feeds/agent-resources.jsonl) - JSONL structured-data feed for agent resources. ## Support Email: hi@oi.mrdk.io