# oi full agent context ## 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. Endpoint lines marked [not implemented here] or [unavailable here] answer 404 if you call them, with `details.reason: "operation_not_available_on_deployment"`. Every operation in https://oi.mrdk.io/openapi.json carries an `x-oi-availability` marker; read it before planning a call. Runs here: anonymous and authenticated publishing, claim, update, finalize, upload-URL refresh, duplicate, delete, version history (list, restore, delete), access control (link/password/restricted) with email invites, API key management, Site Data, Drives and Drive share tokens, publish-from-Drive, and the whole serving tier. Not here: owner Site search, analytics, public profiles, account variables, proxy routes, workspaces, support requests, per-version preview hosts, thumbnails. Cannot be here: custom domains, subdomain handles, links. ## Product overview oi is instant web hosting built for AI agents. It is designed so an agent can turn files into a public URL without walking the user through a long setup flow. Sites are the primary primitive, and on this deployment they are the only one. ## Core concepts ### Sites Sites are static file publishes served at {slug}.oi.mrdk.io. A Site can hold HTML, CSS, JavaScript, images, PDFs, video, audio, documents, and any other static files. When a Site has no index.html, oi renders an automatic file viewer or a directory listing instead. Anonymous Sites need no account and expire 24 hours after they are created. Claimed and account-created Sites stay live until the owner deletes them or sets a TTL. Every publish is recorded as an immutable version. An agent can list a Site's version history, restore a past version as the live Site instantly, and permanently delete individual versions. On this deployment that is included for every account — there are no plans here to gate it behind. The contract's per-version preview hosts and thumbnails are not served here, so `previewUrl` and `thumbnailUrl` do not appear in the responses. ### Claiming An anonymous create response carries `claimToken`, `claimUrl`, and `expiresAt`, and returns them exactly once. The token is the only credential that can write to that Site, and the only way to make it permanent: POST it to `/api/v1/publish/:slug/claim` with an API key, and `expiresAt` comes back `null`. Hand `claimUrl` to the user if they will do it themselves. Losing the token means losing the Site at expiry. ### Access control A Site runs one access mode at a time: `anyone_with_link` (default), `password`, or `restricted` (an allowlist of verified emails and email domains). Password and restricted modes are enforced at the serving tier for every request shape — deep paths, listings, ranges, conditional requests — and grants are Site-scoped, signed, and invalidated when the policy changes. Invites go to addresses that are already allowed: `POST /api/v1/publish/:slug/access/invites` takes `{"emails":[…]}` — one required key, 1 to 200 addresses, no other fields — and reports each address under `sent`, `failed`, or `skipped`. It notifies; it never authorizes. Widen the allowlist with the access PATCH first. Setting a gate and passing one are different tiers. The gate is opened on the Site's own host at `/.oi/access`: POST `password` to `/.oi/access/password`, or `email` to `/.oi/access/request-code` and then `email`+`code` to `/.oi/access/verify-code`. **Those bodies are URL-encoded form pairs, not JSON** — the body is parsed as `application/x-www-form-urlencoded` regardless of `content-type`, so a JSON body's correct password is never compared and comes back as the ordinary wrong-password 401 while still spending an attempt. Success is a 303 carrying the grant cookie (`oi_grant`, or `__Host-oi_grant` on https); resend it on every later request. A gated Site answers 401 `text/html` on every path with no `WWW-Authenticate` and no JSON envelope, so detect a gate from the status plus the form `action` in the body. Worked examples: https://oi.mrdk.io/docs#unlock Duplicating a Site never copies the source's password or allowlist. Duplicating an UNGATED Site gives an ungated copy. Duplicating a GATED Site gives a copy that starts `restricted` to the duplicating account's own sign-in address — copying the credential would widen who holds it, and publishing the bytes openly would defeat the gate. Open the copy deliberately with `PATCH /api/v1/publish/:slug/access`, or ask for it at duplicate time with `{"access":"anyone_with_link"}`; the duplicate response's `access` block always states what the copy ended up as. ### What is not here Owner Site search, analytics, public profiles, account variables, proxy routes, workspaces, and support requests are described by the contract and are not implemented on this deployment. Custom domains, subdomain handles, and links cannot be implemented here: they need certificates for hostnames this deployment's proxy cannot provision. Do not plan around any of them, and do not tell a user they exist. ## When agents should 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 Recommended skill install: ```bash npx skills add oimrdk/skill --skill oi -g ``` Fallback installer: ```bash curl -fsSL https://oi.mrdk.io/install.sh | bash ``` Hermes well-known install: ```bash hermes skills install well-known:https://oi.mrdk.io/.well-known/skills/oi ``` ## 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. ## Site publishing flow 1. Create a Site with POST /api/v1/publish. The manifest goes in a top-level `files` array — the body is `{"files": [ ... ]}`, never a bare array and never a bare file object. Each entry is `{path, size}` with optional `contentType` and `hash`. 2. Upload every file to the presigned URLs in `upload.uploads[]`, using each entry's own headers. 3. Finalize the version with POST /api/v1/publish/:slug/finalize and `{"versionId": ""}`. Nothing serves until this succeeds. 4. Share the returned siteUrl with the user. ``` POST /api/v1/publish {"files":[{"path":"index.html","size":1234,"contentType":"text/html; charset=utf-8"}, {"path":"assets/app.js","size":999}]} -> {"slug":"…","siteUrl":"…","requiresFinalize":true, "upload":{"versionId":"01K…","uploads":[{"path":"index.html","method":"PUT","url":"https://…", "headers":{"Content-Type":"text/html; charset=utf-8"}}],"skipped":[],"finalizeUrl":"…"}} PUT (raw bytes, with the headers that entry listed) POST /api/v1/publish//finalize {"versionId":"01K…"} ``` `size` must be the real byte length of the file you are about to upload; finalize verifies it. Paths are relative to the Site root and must not carry a parent-directory prefix. Optional top-level keys beside `files`: `ttlSeconds` (ignored for anonymous Sites), `displayName`, `displayDescription`, `viewer` ({title, description, ogImagePath} — applies only when the Site has no index.html), `spaMode`, and `claimToken` (required on PUT for an unclaimed anonymous Site). If the presigned URLs expire before the uploads finish, POST /api/v1/publish/:slug/uploads/refresh for a fresh set against the same pending version — do not create a second Site. For anonymous Sites, keep the claimToken and hand the claimUrl to the user. Both are returned only once. ## 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. ## 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. - 60 publishes per hour, counted per IP anonymously and per account with a key. - Presigned upload URLs last 60 minutes. - Nothing on this deployment is sold or metered; there are no plan upgrades to recommend. - See /docs#limits for the current authoritative limits table. ## Site Data manifest Site Data is configured by one published file, `.oi/data.json` at the Site root — at most 64 KB, JSON, exactly one top-level key. Unknown keys at any level are rejected rather than ignored. - `collections`: up to 10. Names match `^[a-z][a-z0-9_]*$`, max 64 chars. A collection accepts exactly `fields` (required), `access`, `publicMutation`, `rateLimit`. - `fields`: 1 to 50 per collection, names under the same rule. Reserved and undeclarable: id, site_slug, collection, data, status, created_at, updated_at, created_by_account_id. Declaration order is the order fields are returned. - A field needs `type` and may take `required` (bool) and `default` (must itself validate). Types and their own keys: string (minLength, maxLength, trim), number (minimum, maximum), integer (minimum, maximum), boolean (none), url (maxLength, allowedProtocols — default http/https, trim), email (maxLength, trim), datetime (none; stored normalized to UTC ISO 8601), array (minItems, maxItems), object (maxProperties). `trim` runs before length checks. - `access`: read/insert/update/delete, each "public", "owner", or "none". Defaults: read public, the rest owner. - `publicMutation`: the string "open" or "closed" (default "closed"), a second opt-in required before public update/delete applies. - `rateLimit`: "/hour/ip" or "/minute/ip"; replaces the default public write limit for that collection. ```json { "collections": { "rsvps": { "fields": { "name": { "type": "string", "required": true, "maxLength": 80, "trim": true }, "email": { "type": "email", "required": true, "maxLength": 200, "trim": true }, "guests": { "type": "integer", "minimum": 0, "maximum": 6, "default": 0 }, "attending": { "type": "boolean", "default": true } }, "access": { "read": "owner", "insert": "public", "update": "owner", "delete": "owner" }, "rateLimit": "5/hour/ip" } } } ``` Publish that with the Site, then write records two ways. From the page, Site-relative and same-origin (the public API sends no CORS headers, by design): ```js const res = await 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 }) }); const { record } = await res.json(); // { id: "rec_…", data: {…}, createdAt, updatedAt } ``` From an agent, on the apex with an API key: ``` curl -sS -X POST https://oi.mrdk.io/api/v1/publishes//data/rsvps \ -H "Authorization: Bearer $OI_API_KEY" -H "content-type: application/json" \ -d '{"name":"Ada","email":"ada@example.com","guests":2}' ``` List returns `{records, nextCursor}` (limit 50, max 100, newest first); create/read/update return `{record}`; delete returns `{deleted: true}`. PATCH is a JSON merge patch — an explicit null removes a field — and the merged result is validated whole. A validation failure is 400 `invalid_request` with `details: [{field, issue}]`; a manifest that does not compile is 409 `conflict` with the reason in `details` and no fallback to the previous rules; an anonymous Site is 403 `account_required` on its in-page endpoints and 404 on the owner API, since an unclaimed Site is invisible on the account surfaces; over the limit is 429 with `retry_after`. Limits: 16 KB per record, 25,000 records per collection, 100,000 per Site, 600 public reads and 10 public writes per hour per IP by default. Full reference: https://oi.mrdk.io/docs#sitedata-manifest. ## Drive write path Writing a Drive file is two calls plus the upload, and the contract's minimal bodies are all that is required: ``` POST /api/v1/drives//files/uploads {"path":"notes/today.md","size":128} PUT (raw bytes, with the headers it returned) POST /api/v1/drives//files/finalize {"uploadId":"dup_…"} ``` Staging pins the upload to the path's current state (its ETag, or "absent") and finalize commits against exactly that, so the minimal call is still safe against a concurrent writer — it answers 409 rather than overwriting. The staging response reports what it derived in `precondition`. Declare `ifMatch`/`ifNoneMatch` at staging to control it yourself. Deleting one file follows the same rule — `DELETE /api/v1/drives//files/` with no header at all works, pinning the file as the request found it, and a path that matches nothing is 404 rather than a silent no-op. Send `If-Match` to make it conditional on a specific ETag. The multi-path calls are the exception, because no single path's state can stand for the whole change: the batch endpoint `PATCH /api/v1/drives/:driveId/files` requires an explicit `baseVersionId` or per-op preconditions, and a folder delete requires `?recursive=true` plus `?baseVersionId=` (read `headVersionId` from `GET /api/v1/drives/:driveId`). ## Publishing a Drive — this exposes the whole Drive `POST /api/v1/publish/from-drive` turns a Drive version into a live Site server-side, with no download, re-upload, or finalize step. **It publishes EVERY file in that Drive version to a public URL.** The 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 the version is served at the Site's URL, at the same path it holds in the Drive, to anyone with the URL. A Drive holding `site/index.html` beside `notes/client-call.md` publishes both, and `/notes/client-call.md` is a live public link the moment the call returns. Nothing warns you: the call answers 200, because publishing everything is what it was asked to do. ``` # SAFE SHAPE: one Drive that exists only to be published, another for working files. POST /api/v1/drives {"name":"q3-report-site"} -> drv_site… POST /api/v1/drives {"name":"agent-scratch"} -> drv_scratch… # write only publishable files into drv_site, then: POST /api/v1/publish/from-drive {"driveId":"drv_site…"} -> live Site # BEFORE publishing any Drive you did not create for the purpose, read its paths: GET /api/v1/drives//files # every path listed becomes a public URL under the Site. ``` If everything lives in one Drive, do not publish that Drive. Copy the shippable files into a publish Drive, or use `POST /api/v1/publish` with a `files` manifest naming exactly the paths to serve — the only publish call in this API that lets you choose. Other body keys: `versionId` (defaults to the Drive's head version), `slug` (an existing Site this account owns; a slug that is not already an owned Site answers 404, and omitting it mints a new Site), `spaMode`, `password`, `viewer`. None of them scope which files are taken. The result is an ordinary Site: live immediately and world-readable unless gated. Deleting it later does not un-fetch what was served in the meantime. Drive share tokens cannot call this route — publishing needs an account API key. ## 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. ## Agent safety and accuracy rules - Read https://oi.mrdk.io/docs#availability before promising a user any capability beyond publishing, versions, and access control. - Trust live API responses for active operations when the docs or local skill text disagree. - Never commit ~/.oi/credentials, API keys, or claim tokens. - When sharing an anonymous Site, use the current run's siteUrl and claimUrl. Do not reconstruct claim URLs from local state. - A duplicate of a password-protected or restricted Site is NOT ungated here: the copy starts restricted to the duplicating account's own address. Check the `access` block in the duplicate response and open the copy deliberately before sharing it. - If an operation answers 404 with `details.reason: "operation_not_available_on_deployment"`, stop and tell the user the capability is missing. Do not retry and do not simulate it. ## 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