---
name: oi
description: >
  oi publishes websites and files to live URLs in seconds. Push HTML,
  documents, images, PDFs, videos, and other static files to live URLs at
  {slug}.oi.mrdk.io. Use when asked to "publish this",
  "host this", "deploy this", "share this on the web", "make a website",
  "put this online", "create a webpage", "generate a URL", "password
  protect this site", "make this site private", "share this site with
  only certain people", "undo that publish", or "roll it back". This
  deployment implements a subset of the oi API contract: read
  https://oi.mrdk.io/docs#availability before promising a capability beyond
  publishing, versions, and access control.
---

# oi

**Skill version: 1.19.0**

oi publishes websites and files to live URLs in seconds.

Everything revolves around one primitive, the **Site**: hand oi a file or folder and get back a live URL at `{slug}.oi.mrdk.io`. Each Site carries an access mode — public link (the default), password, or restricted invite-only.

## What this deployment implements

This is a self-hosted oi deployment. It answers a subset of the oi API contract, and this skill describes the whole contract, so check before you promise:

- **Runs here:** anonymous publishing, claiming an anonymous Site into an account, updating, refreshing expired upload URLs, finalizing, duplicating, deleting, version history (list, restore, delete — included for every account, no plan needed), access control (public link, password, invite-only) with email invites, API key management, Site Data, Drives and Drive share tokens, publish-from-Drive, and the full serving tier.
- **Not implemented here:** owner Site search, analytics, public profiles, account variables, proxy routes, workspaces, support requests, per-version preview hosts, thumbnails.
- **Cannot run here:** custom domains, subdomain handles, and links — they need TLS certificates for hostnames this deployment's proxy cannot provision.

Calling an operation that is not implemented returns `404` with `details.reason: "operation_not_available_on_deployment"`. Do not retry it and do not improvise around it — tell the user the capability is missing. The authoritative list is `x-oi-availability` on each operation in https://oi.mrdk.io/openapi.json, and in prose at https://oi.mrdk.io/docs#availability.

To install or update (recommended): `npx skills add oimrdk/skill --skill oi -g`

For repo-pinned/project-local installs, run the same command without `-g`.

## Current docs

**Before answering questions about oi capabilities, features, or workflows, read the current docs:**

→ **https://oi.mrdk.io/docs**

Read the docs:

- at the first oi-related interaction in a conversation
- any time the user asks how to do something
- any time the user asks what is possible, supported, or recommended
- before telling the user a feature is unsupported

Topics that require current docs (do not rely on local skill text alone):

- Site access control (passwords and restricted access)
- workspaces (team accounts, membership, label URLs)
- Drives and Drive sharing
- custom domains
- Site Data
- public profiles
- proxy routes and service variables
- limits and quotas
- SPA routing
- owner Site search
- Site analytics
- Site version history, previews, and rollback
- error handling and remediation
- feature availability

**If docs and live API behavior disagree, trust the live API behavior.**

When the docs fetch fails or times out, keep going with the local skill plus live API/script output, and let live API behavior win for active operations.

## Requirements

- Required binary: `curl`. `jq` makes reading the JSON responses easier but nothing here needs it.
- Optional environment variable: `$OI_API_KEY`
- Optional Drive token variable: `$OI_DRIVE_TOKEN`
- Optional credentials file: `~/.oi/credentials`
- **Helper scripts are not part of this deployment's install.** `https://oi.mrdk.io/install.sh` writes exactly one file — `<skills dir>/oi/SKILL.md` — and no `scripts/` directory. If your copy of the skill came from a package that bundles `./scripts/publish.sh` and `./scripts/drive.sh`, use them; the flag tables near the end describe them. Otherwise use the API calls below, which are the same three steps the scripts make and need nothing but `curl`.

## Create a site

Three calls: create, upload, finalize. Nothing is live until finalize succeeds.

```bash
# 1. Create. One entry per file, with its byte size. Omit Authorization for an
#    anonymous site (expires in 24h); send it for a permanent one.
curl -sS -X POST https://oi.mrdk.io/api/v1/publish \
  -H "content-type: application/json" \
  -H "X-Oi-Client: my-agent" \
  -d '{"files":[{"path":"index.html","size":128}]}'

# → { "slug": "quiet-meadow-b3t7",
#     "siteUrl": "https://quiet-meadow-b3t7.oi.mrdk.io/",
#     "upload": { "versionId": "01K…",
#                 "uploads": [ { "path": "index.html", "method": "PUT",
#                                "url": "…", "headers": { "Content-Type": "text/html; charset=utf-8" } } ],
#                 "finalizeUrl": "…", "expiresInSeconds": 3600 },
#     "claimToken": "…", "claimUrl": "…", "expiresAt": "…" }   ← anonymous only

# 2. Upload each file to its own url, with the headers that entry listed.
curl -sS -X PUT "$URL" -H "Content-Type: text/html; charset=utf-8" --data-binary @index.html

# 3. Finalize with the versionId from step 1.
curl -sS -X POST https://oi.mrdk.io/api/v1/publish/quiet-meadow-b3t7/finalize \
  -H "content-type: application/json" \
  -d '{"versionId":"01K…"}'
```

The live URL is `siteUrl` from step 1. `size` must be the real byte count of the file you are about to upload.

With no API key the result is an **anonymous site** that expires after 24 hours — keep `claimToken` and hand `claimUrl` to the user, because both are returned exactly once. With a saved API key, the site is permanent and no claim token is issued.

If your install bundles the helper, `./scripts/publish.sh {file-or-dir}` runs those same three steps and prints the live URL.

**File structure:** For HTML sites, `index.html` belongs at the root of the directory being published, never inside a subdirectory — the directory's contents become the site root. Publish `my-site/` where `my-site/index.html` exists; do not publish a parent folder that merely contains `my-site/`.

Raw files publish fine without any HTML: a single file gets a rich auto-viewer (images, PDF, video, audio), and multiple files get an auto-generated directory listing with folder navigation and an image gallery.

## Update an existing site

`PUT /api/v1/publish/{slug}` takes the same body as create and stages a new version; upload and finalize exactly as before.

```bash
curl -sS -X PUT https://oi.mrdk.io/api/v1/publish/quiet-meadow-b3t7 \
  -H "Authorization: Bearer $OI_API_KEY" \
  -H "content-type: application/json" \
  -d '{"files":[{"path":"index.html","size":140}]}'
```

For a still-unclaimed anonymous site, drop the Authorization header and put its token in the body instead: `{"claimToken":"…","files":[…]}`. Sending a file's `hash` (lowercase SHA-256 hex) lets the server skip files it already has — they come back in `upload.skipped` and are copied server-side at finalize. An update never extends an anonymous site's expiry.

With the bundled helper: `./scripts/publish.sh {file-or-dir} --slug {slug}`, which auto-loads the `claimToken` from `.oi/state.json` (override with `--claim-token {token}`).

Every publish records an immutable version. When the user asks to see earlier versions of a Site, undo a publish, or roll back: list history with `GET /api/v1/publish/{slug}/versions`, then restore instantly with `POST /api/v1/publish/{slug}/versions/{versionId}/restore` (a restore keeps the current access mode and password). On this deployment version history is included for every account — there is no plan to upgrade to. Per-version preview hosts and thumbnails are not served here, so `previewUrl` and `thumbnailUrl` do not appear in the listing. A byte-identical republish makes finalize answer `unchanged: true` rather than minting a new version. See https://oi.mrdk.io/docs#versions.

Public profiles are part of the contract and are **not implemented on this deployment**: the profile API answers 404 and `finalize` always reports `addedToProfile: false`.

## Publish to a workspace

**Not implemented on this deployment.** The contract's workspaces are shared team accounts serving at `{label}.{workspace}.oi.mrdk.io`; here there are none. Every API key acts on exactly one personal account, the 23 workspace operations answer 404, and an `X-Oi-Account` selector naming any other account is rejected rather than honored — so `--workspace` has nothing to target.

For team work here: put the Site behind invite-only access (below), which verifies each address by email before serving a byte, and give each agent or tool its own revocable API key.

## Site access control

A Site uses one access mode at a time:

- **anyone_with_link** (default): anyone with the URL can view.
- **password**: visitors must enter a shared password.
- **restricted**: invite-only; only verified email addresses or email domains the owner allows can view.

The contract also defines an **account_members** mode for workspace-owned Sites. There are no workspaces on this deployment, so that mode is unreachable here: `PATCH /api/v1/publish/{slug}/access` with `account_members` answers `409`, as the contract specifies for a personal Site.

**Duplicating a gated Site does not produce an ungated copy here.** The source's password and allowlist are never copied — but a copy of a gated Site starts `restricted` to your own sign-in address rather than world-readable, because publishing the bytes openly would defeat the gate that was on them. Duplicating an ungated Site still gives an ungated copy. To get a public copy of a gated Site, ask for it: `{"access": "anyone_with_link"}` in the duplicate body, or `PATCH /api/v1/publish/{slug}/access` afterwards. The duplicate response's `access` block always states what the copy ended up as — read it rather than assuming.

Access is managed with `GET`/`PATCH /api/v1/publish/{slug}/access` (passwords go through the metadata endpoint). Restricted access needs a claimed Site, and the PATCH replaces both allowlists in full — read, merge, then write. To mail people who are already on the allowlist, `POST /api/v1/publish/{slug}/access/invites` with `{"emails": ["someone@example.com"]}` — 1 to 200 addresses, no other fields; it notifies but never authorizes, so anyone not already allowed comes back under `skipped`.

**Passing a gate is a different tier, on a different host.** The API above sets the policy on the apex; a visitor opens the Site at `{slug}.oi.mrdk.io/.oi/access/...`. If you set a gate you can also need to pass it — to verify the Site works, or to fetch it back:

- `POST /.oi/access/password` with `password=...` (password mode)
- `POST /.oi/access/request-code` with `email=...`, then `POST /.oi/access/verify-code` with `email=...&code=...` (restricted mode)

**These bodies are URL-encoded form pairs, not JSON.** The gate parses the body as `application/x-www-form-urlencoded` no matter what `content-type` says, so a JSON body is not read at all and a correct password inside one comes back as `401` — indistinguishable from a wrong password, and it still spends an attempt against the rate limit. Success is a `303` with a `Set-Cookie` grant (`oi_grant`, or `__Host-oi_grant` over https); keep it and send it on every later request to that Site. A gated Site answers `401 text/html` on every path with no `WWW-Authenticate` header, so detect a gate from the status plus the form `action` in the body.

Before touching access control, read the current docs:

→ **https://oi.mrdk.io/docs#access-control** and **https://oi.mrdk.io/docs#unlock**

## Site Data

When a static Site needs to store records — a form, a survey, an RSVP list, a poll, a shared checklist — it does not need a backend. Publish a `.oi/data.json` manifest with the Site and the records tier turns on. The Site must be account-owned. Until it is claimed the two tiers refuse differently: the Site's own in-page endpoints answer `403 account_required`, while the owner API answers `404` for that slug, because an unclaimed Site is invisible on every account surface. Claim it first, then write.

The manifest is the schema: an undeclared collection does not exist, and an undeclared field is refused on write and hidden on read. Top level is one key, `collections` (max 10, names `^[a-z][a-z0-9_]*$`); each collection takes `fields` (required, 1–50), `access`, `publicMutation`, and `rateLimit`, and nothing else — unknown keys are rejected rather than ignored.

```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"
    }
  }
}
```

Field types are `string`, `number`, `integer`, `boolean`, `url`, `email`, `datetime`, `array`, `object`. Each takes `required` and `default` plus its own constraints: lengths and `trim` for text, `minimum`/`maximum` for numbers, `allowedProtocols` for URLs, `minItems`/`maxItems` for arrays, `maxProperties` for objects. `access` actions are `public`, `owner`, or `none` (defaults: read public, the rest owner), public writes must come from the Site's own page, and public update/delete need `"publicMutation": "open"` as a second opt-in.

The page writes to its own origin with a relative URL:

```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();
```

An agent uses the owner API with the API key:

```bash
curl -sS -X POST https://oi.mrdk.io/api/v1/publishes/{slug}/data/rsvps \
  -H "Authorization: Bearer $OI_API_KEY" -H "content-type: application/json" \
  -d '{"name":"Ada","email":"ada@example.com","guests":2}'
```

Lists return `{records, nextCursor}`; writes return `{record}`. A record that breaks the schema is `400` with `details: [{field, issue}]`; a manifest that does not compile is `409` with the reason and no fallback to the old rules, so validate the JSON before publishing. Records survive republishing the Site's files, and a publish that omits the manifest keeps the configuration already in force — to switch Site Data off, publish `{"collections": {}}`. Full reference: https://oi.mrdk.io/docs#sitedata-manifest.

## Use a Drive

Reach for a Drive when the user wants private cloud storage for agent files: documents, context, memory, plans, assets, media, research, code — anything that should persist without becoming a website. Drives run on this deployment.

Every signed-in account has a default Drive named `My Drive`; `GET /api/v1/drives/default` returns it and creates it on first use.

```bash
curl -sS https://oi.mrdk.io/api/v1/drives/default -H "Authorization: Bearer $OI_API_KEY"
curl -sS https://oi.mrdk.io/api/v1/drives/{driveId}/files -H "Authorization: Bearer $OI_API_KEY"
curl -sS https://oi.mrdk.io/api/v1/drives/{driveId}/files/notes/today.md -H "Authorization: Bearer $OI_API_KEY"
```

If your install bundles the helper, the same operations are `./scripts/drive.sh default`, `ls`, `cat`, `put` and `share`.

Writing a file through the API directly is two calls plus the upload, and the required fields are the only ones you need: `POST /api/v1/drives/{driveId}/files/uploads` with `{"path","size"}` returns an `uploadId` and a presigned `url`; `PUT` the bytes there; `POST /api/v1/drives/{driveId}/files/finalize` with `{"uploadId"}` commits. Staging pins the upload to the path's current ETag (or to its absence), so that minimal sequence still refuses to clobber a concurrent writer — it answers `409` instead — and the staging response reports the precondition it derived. Send `ifMatch` or `ifNoneMatch` at staging to choose one yourself. Deleting one file follows the same rule: `DELETE /api/v1/drives/{driveId}/files/{path}` needs no header, pins the file as the request found it, and answers 404 on a path that matches nothing; add `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 atomic batch `PATCH /api/v1/drives/{driveId}/files` requires an explicit `baseVersionId` or per-op preconditions, and a folder delete requires `?recursive=true` together with `?baseVersionId=` (the Drive's `headVersionId`).

Scoped Drive tokens are the tool for agent-to-agent handoff. On receiving an `oi_drive` share block: use its `token` as `Authorization: Bearer <token>` against `api_base`, respect `pathPrefix` when present, and preserve ETags on writes. A `pathPrefix` of `null` grants full-Drive access. Prefer `./scripts/drive.sh` when the skill is installed; otherwise call the listed API operations directly.

Drive files are private. Never describe a Drive path as a public URL — publish the files as a Site if the user wants one.

### Publishing a Drive publishes ALL of it

`POST /api/v1/publish/from-drive` turns a Drive version into a live Site server-side, with no download, re-upload or finalize. **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 and a handoff meant for another agent are all served at the Site's URL, at the paths they hold in the Drive, to anyone who has the URL. Nothing warns you — the call answers `200`, because publishing everything is what it was asked to do.

Keep a Drive per Site. One Drive that exists only to be published, a different one for the agent's own working files, and "publish the whole Drive" stays true as both grow:

```bash
# The Drive that will become the Site. Working notes never go in here.
curl -sS -X POST https://oi.mrdk.io/api/v1/drives -H "Authorization: Bearer $OI_API_KEY" \
  -H "content-type: application/json" -d '{"name":"q3-report-site"}'
curl -sS -X POST https://oi.mrdk.io/api/v1/drives -H "Authorization: Bearer $OI_API_KEY" \
  -H "content-type: application/json" -d '{"name":"agent-scratch"}'

# Publish the site Drive. driveId is the only required key.
curl -sS -X POST https://oi.mrdk.io/api/v1/publish/from-drive -H "Authorization: Bearer $OI_API_KEY" \
  -H "content-type: application/json" -d '{"driveId":"drv_site…"}'
```

Before publishing any Drive you did not create for the purpose, list it first — `GET /api/v1/drives/{driveId}/files` — and read the paths back: every one of them becomes a public URL. If anything there should stay private, do not publish that Drive. Stage the shippable files into a publish Drive instead, or use `POST /api/v1/publish` with a `files` manifest naming exactly the paths to serve, which is the only publish call here that lets you choose. The other body keys (`versionId`, `slug`, `spaMode`, `password`, `viewer`) change which version and how it is served; none of them scope which files are taken, and deleting the Site later does not un-fetch what was served. Drive share tokens cannot call this route — it needs an account API key. Full reference: https://oi.mrdk.io/docs#publish-from-drive.

## Client attribution

Send `X-Oi-Client: <agent>/<tool>` on publish API calls so oi can track reliability by agent:

```bash
curl -sS -X POST https://oi.mrdk.io/api/v1/publish -H "X-Oi-Client: cursor/my-tool" …
```

It is optional and never changes the result; an unrecognized or missing value is ignored. With the bundled helper, `--client cursor` sets it for you.

## API key storage

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

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

To store a key, write it to the credentials file:

```bash
mkdir -p ~/.oi && echo "{API_KEY}" > ~/.oi/credentials && chmod 600 ~/.oi/credentials
```

**IMPORTANT**: The moment an API key arrives, save it — run the command above yourself rather than asking the user to. Skip CLI key flags (e.g. `--api-key`) in interactive sessions; the credentials file is the preferred storage.

Never commit credentials or local state files (`~/.oi/credentials`, `.oi/state.json`) to source control.

## Getting an API key

To move from anonymous (24h) to permanent sites:

1. Ask the user for their email address.
2. Request a one-time sign-in code:

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

3. Tell the user: "Check your inbox for a sign-in code from oi and paste it here."
4. Verify the code and get the API key:

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

5. Save the returned `apiKey` yourself (do not ask the user to do this):

```bash
mkdir -p ~/.oi && echo "{API_KEY}" > ~/.oi/credentials && chmod 600 ~/.oi/credentials
```

## State file

After every site create/update, the script writes `.oi/state.json` in the working directory:

```json
{
  "publishes": {
    "quiet-meadow-b3t7": {
      "siteUrl": "https://quiet-meadow-b3t7.oi.mrdk.io/",
      "claimToken": "abc123",
      "claimUrl": "https://oi.mrdk.io/claim?slug=quiet-meadow-b3t7&token=abc123",
      "expiresAt": "2026-02-18T01:00:00.000Z"
    }
  }
}
```

Checking this file before creating or updating sites is a fine way to find prior slugs.
Treat `.oi/state.json` strictly as an internal cache.
Never present the local file path as a URL, and never rely on it as the source of truth for auth mode, expiry, or claim URL.

## What to tell the user

For published sites:

- Always share the `siteUrl` from the current script run.
- Read and follow `publish_result.*` lines from script stderr to determine auth mode.
- When `publish_result.account_url` is non-empty (workspace publishes), present it as the primary team URL alongside `siteUrl`.
- When `publish_result.auth_mode=authenticated`: tell the user the site is **permanent** and saved to their account. No claim URL is needed.
- When `publish_result.auth_mode=anonymous`: tell the user the site **expires in 24 hours**. Share the claim URL (when `publish_result.claim_url` is non-empty and starts with `https://`) so they can keep it permanently, and warn that claim tokens appear once and cannot be recovered.
- Never point the user at `.oi/state.json` for claim URLs or auth status.

For Drives:

- Do not describe Drive files as public URLs.
- Tell the user Drive contents stay private unless shared with a scoped token — or until someone publishes the Drive.
- Never call `POST /api/v1/publish/from-drive` on a Drive that holds anything the user has not asked to make public: it publishes **every file in the version**, and there is no way to scope it. List the Drive's files and check the paths first, or publish a Drive that exists only for that Site.
- When sharing access with another agent, prefer a scoped token with a narrow `pathPrefix` and a short TTL.

## publish.sh options

These apply only if your copy of the skill bundles `./scripts/publish.sh`; this deployment's installer does not ship it. Everything the flags do is reachable through the API calls above.

| Flag                   | Description                                  |
| ---------------------- | -------------------------------------------- |
| `--slug {slug}`        | Update an existing site instead of creating |
| `--workspace {subdomain}` | Publish into a workspace — **nothing to target on this deployment**; the selector is rejected |
| `--claim-token {token}`| Override claim token for anonymous updates    |
| `--title {text}`       | Viewer title (non-HTML sites)             |
| `--description {text}` | Viewer description                            |
| `--ttl {seconds}`      | Set expiry (authenticated only)               |
| `--client {name}`      | Agent name for attribution (e.g. `cursor`)    |
| `--base-url {url}`     | API base URL (default: `https://oi.mrdk.io`)    |
| `--allow-nonoi-base-url` | Allow sending auth to non-default `--base-url` |
| `--api-key {key}`      | API key override (prefer credentials file)    |
| `--spa`                | Enable SPA routing (serve index.html for unknown paths) |

## Beyond the basics

Drive work goes through the Drive API (or `./scripts/drive.sh` if you have it). For wider account and Site management — Site Data, delete, metadata, access control, versions, duplication, and more — read the current docs. Note that search, analytics, profiles, variables, proxy routes and domains are contract features this deployment does not answer:

→ **https://oi.mrdk.io/docs**

Full docs: https://oi.mrdk.io/docs
