HTTP API

Serve HTTP endpoints under your app's own API path.

Agent-readable version: Markdown ยท llms.txt

A Rome App can serve HTTP endpoints. The daemon exposes two surfaces and sends each request to a single handler your app provides:

  • /api/apps/<appId>/... is the dashboard/app-UI surface used by fetchAppApi.
  • /api/app-api/<appId>/... is the public app-api surface used by third-party callbacks and webhooks when api.noAuth allows the subpath.

Handlers get a request object in a fixed shape and return a standard web Response.

The HTTP API is declared in app.yaml and implemented in src/api/index.ts.

Defining the handler

Declare the API entry in the manifest:

# app.yaml
api:
  entry: api/index # path under the app root, no extension

The entry module must export function createApiHandler(ctx), returning an object that implements RomeAppApiHandler. The handler is built once with the app's RomeAppContext, and its handle method is called once per request.

import type {
  RomeAppApiHandler,
  RomeAppApiRequest,
  RomeAppContext,
} from "@rome-os/app-runtime";

class MyApiHandler implements RomeAppApiHandler {
  constructor(private readonly ctx: RomeAppContext) {}

  async handle(request: RomeAppApiRequest): Promise<Response> {
    const route = request.path.join("/");

    if (request.method === "GET" && route === "status") {
      return Response.json({
        appId: this.ctx.app.id,
        version: this.ctx.app.version,
      });
    }

    return Response.json({ error: "not_found" }, { status: 404 });
  }
}

export function createApiHandler(ctx: RomeAppContext): RomeAppApiHandler {
  return new MyApiHandler(ctx);
}

The RomeAppApiHandler interface is a single method:

export interface RomeAppApiHandler {
  handle(request: RomeAppApiRequest): Promise<Response>;
}

Mounting

The dashboard surface is mounted at /api/apps/<appId>/[[...route]]. The public surface is mounted at /api/app-api/<appId>/[[...route]]. Both reach the same handler. The prefix is removed before the request reaches you, so request.path holds only the parts that are left and you route on those.

Some dashboard endpoints are reserved by the host and win before the app dispatcher: /api/apps/<appId>/manifest, /api/apps/<appId>/icon, POST /api/apps/<appId>/publish, and the bare app root itself โ€” GET /api/apps/<appId> is the host's deployment-status endpoint and DELETE uninstalls the app, so fetchAppApi("") never reaches your handler. Use a clear subroute such as /api/apps/<appId>/state or /api/app-api/<appId>/webhooks/stripe for your own API.

The ctx passed in is the app's RomeAppContext. Fields you'll use often:

FieldUse
ctx.app.id / ctx.app.versionApp identity
ctx.runAction(canonicalId, args)Call an action by canonical <app-id>:<local-name> ID
ctx.favors.requestAction(...)Create a favor request action from an authenticated request
ctx.dbThe app's database context (Drizzle)
ctx.repositoriesShared repositories (e.g. settings)
ctx.logApp logger

Request and response

The request object is in a fixed shape that doesn't depend on how it arrived:

export interface RomeAppApiRequest {
  method: string;
  path: string[];
  headers: Record<string, string>;
  query: URLSearchParams;
  body?: Uint8Array;
  caller:
    | { kind: "guardian"; userId: string; via: "cookie" | "loopback" }
    | { kind: "visitor"; accountId: string; email: string }
    | { kind: "anonymous" };
}
FieldNotes
methodThe HTTP method, e.g. "GET", "POST".
pathPath parts after the app API prefix. /api/apps/x/notes/42 and /api/app-api/x/notes/42 both produce ["notes", "42"].
headersRequest headers as a flat string map. Identity headers (X-Rome-User-Id, X-Rome-Visitor-*) are stripped by the host โ€” use caller instead.
queryA URLSearchParams instance โ€” read with .get() / .getAll().
bodyThe raw request bytes as Uint8Array, or undefined. Not parsed for you.
callerWho is calling, resolved server-side by the host. The single trustworthy identity answer โ€” gate owner-only routes with caller.kind !== "guardian", and read the Rome Cloud visitor identity from the visitor variant. The visitor's favor token is kept server-side; create favor requests through ctx.favors. See Public Access & Caller Identity.

The handler returns a standard web Response. Response.json(...) is the easy way to send JSON; pass a status in the second argument when you need something other than 200.

Reading query parameters

request.query is a URLSearchParams, not a plain object. Use .get(key) for the first value (or null) and .getAll(key) for every value when a key appears more than once.

// GET /api/apps/<appId>/notes?limit=20&cursor=abc&tag=foo&tag=bar
// or GET /api/app-api/<appId>/notes?limit=20&cursor=abc&tag=foo&tag=bar
const limit = Number(request.query.get("limit") ?? "20");
const cursor = request.query.get("cursor") ?? undefined;
const tags = request.query.getAll("tag"); // ["foo", "bar"]

Watch out: request.query.limit is always undefined. URLSearchParams does not expose keys as properties. Always call .get("limit").

Decoding raw bodies

The daemon does not parse the request body for you. request.body is the raw bytes (Uint8Array | undefined), so casting it straight to an object type compiles but gives undefined for every property at runtime โ€” you'd be casting a Uint8Array. Decode and parse it yourself.

type JsonBody<T> =
  | { ok: true; value: T }
  | { ok: false; reason: "empty" | "parse_error" };

function readJsonBody<T>(request: RomeAppApiRequest): JsonBody<T> {
  if (!request.body || request.body.byteLength === 0) {
    return { ok: false, reason: "empty" };
  }
  try {
    return { ok: true, value: JSON.parse(new TextDecoder().decode(request.body)) as T };
  } catch {
    return { ok: false, reason: "parse_error" };
  }
}

The two-part result lets callers tell an empty body from broken JSON and return the right error. Note that JSON.parse(...) as T is only a cast โ€” add a runtime schema check (zod, a manual typeof, etc.) before you trust the value on write paths.

Watch out:

  • request.body is undefined for empty bodies and for GET requests. Null-check before decoding.
  • For binary uploads, pass the Uint8Array to a binary parser instead of TextDecoder.

Routing and auth

There is one handler per app; routing is path-based and you write it inside handle. Match on request.method and request.path (or the joined request.path.join("/")), and return a 404 Response for anything that doesn't match. For path parameters, look at the individual parts and the array length.

async handle(request: RomeAppApiRequest): Promise<Response> {
  const route = request.path.join("/");

  // GET /api/apps/<appId>/notes/:id
  if (request.method === "GET" && request.path[0] === "notes" && request.path.length === 2) {
    const id = request.path[1];
    return Response.json({ id });
  }

  // POST /api/apps/<appId>/notes โ€” JSON write
  if (request.method === "POST" && route === "notes") {
    const parsed = readJsonBody<{ title?: string; content?: string }>(request);
    if (!parsed.ok) {
      const error = parsed.reason === "empty" ? "request body required" : "invalid JSON body";
      return Response.json({ error }, { status: 400 });
    }
    const body = parsed.value;
    if (!body.title || !body.content) {
      return Response.json({ error: "title and content required" }, { status: 400 });
    }
    // ...
  }

  return Response.json({ error: "not_found" }, { status: 404 });
}

For more involved routing, branch further inside the handler or pull in a small router.

Calling actions from a route

Routes should hand domain work to actions instead of redoing it. ctx.runAction(canonicalId, args) returns an ActionResult; the ID is always <app-id>:<local-name>, even for your own app. Check status before reading .data:

const result = await this.ctx.runAction("notes:notes_list", { limit, cursor, tags });
if (result.status !== "ok") {
  const error =
    result.status === "error"
      ? result.error
      : `notes:notes_list returned ${result.status}`;
  return Response.json({ error }, { status: 500 });
}
return Response.json({ items: result.data });

ActionResult is a union of ok, error, pending_approval, pending_interaction, handoff, and place_widget. Decide how a route turns each non-ok status into an HTTP response; the example above folds everything that is not ok into a 500.

Public (noAuth) endpoints

By default, the public /api/app-api/<appId>/* surface is locked behind the same session check as the rest of the instance. To open endpoints to the public internet (third-party webhooks, public callbacks), opt those public subpaths out of the session check in app.yaml:

api:
  entry: api/index
  # Open every sub-path of this app:
  # noAuth: true
  # Or open only specific sub-paths (trailing /* makes the entry a prefix match):
  noAuth:
    - /webhooks/stripe
    - /public/*

noAuth: true opens every public app-api subpath; an array opens only the listed subpaths, where a trailing /* makes the entry a prefix match. Everything else stays behind the session check. Treat noAuth routes as untrusted input: check the body and verify any webhook signature yourself inside the handler.

The dashboard /api/apps/<appId>/* surface remains the app UI surface. Use fetchAppApi from your web bundle for signed-in dashboard calls, and give third parties the /api/app-api/<appId>/... URL for public callbacks.

noAuth is one of three access tiers. If human visitors should open the app's page anonymously (not just call webhook endpoints), or you need to tell the guardian apart from public visitors inside your handler, see Public Access & Caller Identity.

On this page