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 byfetchAppApi./api/app-api/<appId>/...is the public app-api surface used by third-party callbacks and webhooks whenapi.noAuthallows 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 extensionThe 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:
| Field | Use |
|---|---|
ctx.app.id / ctx.app.version | App 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.db | The app's database context (Drizzle) |
ctx.repositories | Shared repositories (e.g. settings) |
ctx.log | App 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" };
}| Field | Notes |
|---|---|
method | The HTTP method, e.g. "GET", "POST". |
path | Path parts after the app API prefix. /api/apps/x/notes/42 and /api/app-api/x/notes/42 both produce ["notes", "42"]. |
headers | Request headers as a flat string map. Identity headers (X-Rome-User-Id, X-Rome-Visitor-*) are stripped by the host โ use caller instead. |
query | A URLSearchParams instance โ read with .get() / .getAll(). |
body | The raw request bytes as Uint8Array, or undefined. Not parsed for you. |
caller | Who 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.limitis alwaysundefined.URLSearchParamsdoes 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.bodyisundefinedfor empty bodies and for GET requests. Null-check before decoding.- For binary uploads, pass the
Uint8Arrayto a binary parser instead ofTextDecoder.
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.