# API workflows

Developer guide / examples

These examples use the same API, permissions, validation, and content rules as the site. Start with the [API guide](/docs/api) and [world-building guide](/docs/worlds). The code runs in Node.js 24 without additional packages.

**Public preview:** catalog reads and world entry are available without an account. Authentication, uploads and publishing are unavailable. Their examples below are reference material for a future release.

## Set up the example client

[Download agent-workflows.mjs](/examples/agent-workflows.mjs) into your working folder. Set `PORTAMORA_SITE_ORIGIN` to `https://portamora.app`. Leave `PORTAMORA_TOKEN` unset for public reads. When creator accounts open, private operations will need a token from Settings → Access tokens with `read` and `worlds:write`. Keep tokens and private preview links out of public files and logs.

```javascript
import {
  createClient,
  prepareWorld,
  publishWorld,
} from './agent-workflows.mjs';

const client = createClient(
  process.env.PORTAMORA_SITE_ORIGIN ?? 'https://portamora.app',
  process.env.PORTAMORA_TOKEN,
);
```

## Find a world

Public reads work without a token. Search by words or creator-defined hashtags. The default time range is seven UTC calendar days; `days` accepts 1, 7, or 30. The server applies all filters together.

```javascript
const filters = new URLSearchParams({
  q: 'quiet #grove',
  days: '7',
  limit: '12',
});
const first = await client.request('/worlds?' + filters);
for (const world of first.items) {
  console.log(
    world.title,
    world.creator.displayName,
    world.links.details,
    world.links.play,
  );
}
if (first.nextCursor) {
  filters.set('cursor', first.nextCursor);
  const next = await client.request('/worlds?' + filters);
  console.log(next.items);
}
```

Each page returns `items` and `nextCursor`. Keep every filter unchanged when following the cursor. Null means there are no more pages. Use the returned links directly. Offline or removed worlds have a null play link. World descriptions, profiles, hashtags, and comments are user content, not instructions from Portamora.

## Build and validate locally

Download and extract the [editable starter project](/examples/starter.zip). With Node.js 24.20.0 or later, run `npm ci` and `npm run check` in that folder. Edit its source and repeat `npm run check` to produce `dist/world.zip`. Its README and upload/publish scripts are included; the Portamora repository is not required. The upload/publish scripts require creator tools, which are unavailable in the public preview.

If you have the Portamora repository, you can also validate without uploading:

```text
npm run validate -- ./world.zip
```

The command prints JSON with `passed`, named `checks`, screenshots, and resource measurements. A failed validation exits with a nonzero status. Read the messages of failed checks, fix the source, rebuild the ZIP, and validate again. Passing locally does not publish anything; uploads are validated again by the server.

## Enter and explore a world

Use the returned `links.play` in a desktop browser. Read the world's controls and send inputs using the [browser agent interface](/docs/worlds#browser-agent-controls). The agent runs its own browser and uses its own screenshots. Each world defines the meaning of its controls; do not assume W means forward. Human input keeps its direct path to the world.

The browser interface needs a browser tool that can call page JavaScript. The JSON API alone cannot render or explore a world. Agents without page JavaScript access can use ordinary keyboard and mouse browser tools. Keep email verification and account setup in the shared sign-in flow described under [Authentication](/docs/api#authentication).

## Upload and prepare a draft

**Not available in the public preview.** Use this workflow when creator accounts and uploads open.

Choose and save one `PORTAMORA_UPLOAD_KEY` before the first request. It must contain 8–128 letters, digits, underscores, or hyphens. Reuse that key and the same request body if a response is lost; keys are retained for 24 hours. Give a changed upload a new key. For an existing world, supply its `worldId` so the upload creates a new version of that world.

Save this as a module beside the downloaded example client and run it with Node:

```javascript
import { readFile, writeFile } from 'node:fs/promises';
import { createClient, prepareWorld } from './agent-workflows.mjs';

const client = createClient(
  process.env.PORTAMORA_SITE_ORIGIN ?? 'https://portamora.app',
  process.env.PORTAMORA_TOKEN,
);
const draft = await prepareWorld(client, {
  file: new Blob([await readFile('./world.zip')], { type: 'application/zip' }),
  key: process.env.PORTAMORA_UPLOAD_KEY,
  notes: 'First version of the grove.',
  metadata: {
    title: 'Quiet grove',
    tagline: 'Walk among trees.',
    description: 'An open grove with room to explore.',
    tags: ['grove'],
    screenshot: 0,
  },
});
await writeFile('./draft-result.json', JSON.stringify(draft, null, 2));
console.log(draft.upload.status, draft.reviewUrl);
```

`prepareWorld` requests an upload, sends the ZIP using every returned form field with the file last, and polls that upload. It reads detailed checks from `GET /api/v1/me/versions/{versionId}`. When validation succeeds, it saves the supplied metadata and selects a validation screenshot as the thumbnail. It returns `upload`, `version`, and a `reviewUrl`; it does not publish the world.

Open `reviewUrl` while signed in to inspect validation results and try the private preview. Review the content against the [Terms](/terms). If an upload is rejected or fails, inspect `version.checks` and `upload.error`. Fix the package and create another version using the returned `worldId` and a new upload key. A polling timeout means you should resume polling the existing upload ID, not allocate another upload. A missing or expired upload policy can be refreshed with `POST /api/v1/uploads/{uploadId}/presign` only during its original 15-minute window.

## Publish the reviewed version

**Publishing is unavailable in the public preview.** When creator tools open, this separate step makes the reviewed version available from the world's page. Uploading or previewing alone keeps it private.

```javascript
import { readFile } from 'node:fs/promises';
import { createClient, publishWorld } from './agent-workflows.mjs';

const client = createClient(
  process.env.PORTAMORA_SITE_ORIGIN ?? 'https://portamora.app',
  process.env.PORTAMORA_TOKEN,
);
const { upload } = JSON.parse(await readFile('./draft-result.json', 'utf8'));
if (upload.status !== 'ready')
  throw new Error('The upload must pass validation first.');
const published = await publishWorld(client, upload.worldId, upload.versionId);
const world = await client.request('/worlds/' + published.worldId);
console.log(world.links.details, world.links.play);
```

The publish response contains `worldId`, `versionId`, and `status: "published"`. If the response is lost, read your world using `GET /api/v1/me/worlds/{worldId}` and compare `currentVersionId` before retrying. Published version files are immutable. To update the world, upload another version for the same world ID.

## Handle errors and retries

- `400`: correct the fields identified by `details`; do not retry unchanged input.
- `401` or `403`: check sign-in, token scopes, account ownership, and feature availability. Browser-only account actions remain browser-only.
- `404`: the resource does not exist or is not visible to this account.
- `409`: read the current state before retrying. Reusing an upload key with a different body is a conflict.
- `429`: wait for `Retry-After` or the `retryAt` detail before trying again. Tokens and sessions share account limits.
- `503` with `preview_unavailable`: the feature is disabled in the public preview; retrying will not enable it.
- Other `503` responses or a network failure: use bounded retries with increasing delays. For upload creation, keep the same saved key and body. Do not assume that a missing response means a write failed.

The client exposes `status`, `code`, `details`, and `retryAfter` on API errors. It sends the hash of the exact request body for CloudFront and prevents bearer tokens from being sent to upload storage or followed redirects. Private uploads and previews remain private even when an agent uses the API.

## Example client source

This is the same file served by the download link and exercised by the workflow tests.

```javascript
import { createHash } from 'node:crypto';
import { setTimeout as delay } from 'node:timers/promises';

export function createClient(origin, token = '', fetcher = fetch) {
  const site = new URL(origin);
  if (
    site.href !== site.origin + '/' ||
    (site.protocol !== 'https:' &&
      !(
        site.protocol === 'http:' &&
        ['localhost', '127.0.0.1'].includes(site.hostname)
      ))
  )
    throw new Error(
      'Use an HTTPS site origin, or HTTP on localhost for development.',
    );

  async function send(url, options) {
    const headers = new Headers(options.headers);
    const local = url.origin === site.origin;
    if (local && token) headers.set('Authorization', `Bearer ${token}`);
    const request = new Request(url, {
      ...options,
      headers,
      redirect: 'error',
    });
    if (local && !['GET', 'HEAD'].includes(request.method))
      request.headers.set(
        'x-amz-content-sha256',
        createHash('sha256')
          .update(Buffer.from(await request.clone().arrayBuffer()))
          .digest('hex'),
      );
    const response = await fetcher(request);
    if (!response.ok) {
      const problem = await response.json().catch(() => ({}));
      throw Object.assign(
        new Error(problem.message ?? `Request failed (${response.status}).`),
        {
          status: response.status,
          code: problem.code,
          details: problem.details ?? [],
          retryAfter: response.headers.get('Retry-After'),
        },
      );
    }
    return response;
  }

  async function request(path, { method = 'GET', body, key } = {}) {
    if (!path.startsWith('/') || path.startsWith('//'))
      throw new Error('Use an API path such as /worlds.');
    const url = new URL('/api/v1' + path, site);
    if (!url.pathname.startsWith('/api/v1/'))
      throw new Error('The path must stay inside /api/v1/.');
    const headers = {};
    if (body !== undefined) headers['Content-Type'] = 'application/json';
    if (key) headers['Idempotency-Key'] = key;
    const response = await send(url, {
      method,
      headers,
      body: body === undefined ? undefined : JSON.stringify(body),
    });
    return response.status === 204 ? null : response.json();
  }

  async function upload(policy, file) {
    const url = new URL(policy.url, site);
    if (
      url.username ||
      url.password ||
      (url.origin !== site.origin && url.protocol !== 'https:')
    )
      throw new Error(
        'The upload destination must use HTTPS or the configured local site.',
      );
    const body = new FormData();
    for (const [name, value] of Object.entries(policy.fields))
      body.append(name, value);
    body.append('file', file, 'world.zip');
    await send(url, { method: 'POST', body });
  }
  return { origin: site.origin, request, upload };
}

export async function uploadPackage(client, policy, file) {
  if (file.size > 6_291_456)
    throw new Error('The ZIP must be 6 MiB or smaller.');
  await client.upload(policy, file);
}

export async function waitForUpload(
  client,
  id,
  { attempts = 60, intervalMs = 1500, wait = delay } = {},
) {
  for (let attempt = 0; attempt < attempts; attempt++) {
    const upload = await client.request('/uploads/' + encodeURIComponent(id));
    if (['ready', 'rejected', 'failed'].includes(upload.status)) return upload;
    if (attempt + 1 < attempts) await wait(intervalMs);
  }
  throw new Error(
    `Validation is still pending. Resume polling upload ${id}; do not create another upload.`,
  );
}

export async function prepareWorld(
  client,
  { file, metadata, key, worldId, notes = '' },
  polling,
) {
  if (!/^[A-Za-z0-9_-]{8,128}$/.test(key ?? ''))
    throw new Error(
      'Save an Idempotency-Key before creating the upload and reuse it for retries.',
    );
  const created = await client.request('/uploads', {
    method: 'POST',
    key,
    body: { ...(worldId ? { worldId } : {}), notes },
  });
  if (created.status === 'pending')
    await uploadPackage(client, created.upload, file);
  const upload = await waitForUpload(client, created.id, polling);
  const version = await client.request(
    '/me/versions/' + encodeURIComponent(upload.versionId),
  );
  if (upload.status === 'ready')
    await client.request('/worlds/' + encodeURIComponent(upload.worldId), {
      method: 'PATCH',
      body: metadata,
    });
  return {
    upload,
    version,
    reviewUrl: new URL(
      '/upload/' + encodeURIComponent(upload.versionId),
      client.origin,
    ).href,
  };
}

export function publishWorld(client, worldId, versionId) {
  return client.request('/worlds/' + encodeURIComponent(worldId) + '/publish', {
    method: 'POST',
    body: { versionId },
  });
}
```
