Favor Request Actions

Charge favors before Rome dispatches one of your app's actions.

Agent-readable version: Markdown · llms.txt

Favor request actions let an app ask the current Rome Cloud visitor to pay favors before Rome runs an action on the app owner's instance. Use them when a public or shared app surface offers a paid operation: your app creates a favor action request, the visitor approves or declines it, and Rome dispatches the configured local action only after the payment settles.

Note: Favor request actions include a transaction fee. For a limited time, that fee is 0%.

Experimental: favorRequirement is an experimental action feature. Its schema and runtime behavior may change while favor-gated app workflows are still being developed.

This is different from requiresApproval on an action. requiresApproval pauses the guardian's own action call for permission; a favor request action charges a visitor account and then queues an owner-side action dispatch. An action cannot set both requiresApproval: true and favorRequirement.

Flow

  1. Add favorRequirement to the top-level action's action.yaml.
  2. Implement and list that action in app.yaml.
  3. From an authenticated app API handler, call ctx.favors.requestAction(...).
  4. Send the returned authorizationUrl to the browser when the result is pending_consent.
  5. After the visitor pays, Rome queues the request and the owner's instance runs the configured action.

Request creation must happen from the app API runtime. The browser should call your app API with fetchAppApi; do not post directly to Rome's internal /favors/action-requests endpoint.

Declare the favor requirement

Add favorRequirement to the action's action.yaml. amount is a positive integer number of favors — a single request is capped at 100,000 favors, enforced by Rome Cloud when the request is created, not at install; title and summary are shown on the payment screen; and displayFields selects values from the validated action args.

The payment recipient is always the hosted app owner. There is no recipient field.

The app manifest only lists the action artifact:

formatVersion: 2
id: report-shop
name: Report Shop
version: 1.0.0
description: Sell report summaries for favors.

api:
  entry: api/index

actions:
  - actions/summarize-report

The action carries its own favor requirement:

name: summarize_report
type: custom
description: Create a concise summary from a report URL.
entry: ./index.ts
complexity: moderate
speed: slow
reliability: medium
sideEffects: write
favorRequirement:
  amount: 25
  title: Summarize a report
  summary: Create a concise summary from a report URL.
  displayFields:
    - label: Report
      from: $.reportUrl
    - label: Tone
      from: $.tone

The action implementation must expose an inputSchema (defineAction builds one from your Zod schema) — a favor-gated action without one is never synced to Rome Cloud. Rome derives the payment definition from the registered action config plus that input schema.

displayFields[].from values that start with $. are pointers into the validated args. A template must be the whole value ($.reportUrl), not a string with interpolation inside it.

Only the action named by the favor request is charged. If that action calls another action, a nested favorRequirement on the child action is ignored for that execution.

The action definition's name is local (summarize_report above), but favor requests use its canonical ID: <app-id>:<local-name>. Rome Cloud rejects a request whose namespace does not match the requesting app.

Create the request from your API

Call ctx.favors.requestAction from your app API handler. This call uses the authenticated request's visitor session, including a server-side favor token that browser code cannot read.

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

function readJson<T>(request: RomeAppApiRequest): T {
  if (!request.body) throw new Error("request body required");
  return JSON.parse(new TextDecoder().decode(request.body)) as T;
}

export function createApiHandler(ctx: RomeAppContext): RomeAppApiHandler {
  return {
    async handle(request) {
      if (request.method !== "POST" || request.path.join("/") !== "summaries") {
        return Response.json({ error: "not_found" }, { status: 404 });
      }

      const body = readJson<{ reportUrl: string; tone?: string; returnTo?: string }>(request);
      const payerId =
        request.caller.kind === "visitor" ? request.caller.accountId : "anonymous";
      const result = await ctx.favors.requestAction({
        actionName: "report-shop:summarize_report",
        args: {
          reportUrl: body.reportUrl,
          tone: body.tone ?? "neutral",
        },
        taskRef: { kind: "summary", reportUrl: body.reportUrl },
        idempotencyKey: `summary:${body.reportUrl}:${payerId}`,
        // Where the payer lands after paying or declining — typically the page
        // that asked, forwarded from the browser. Must be a path inside
        // /apps/<appId>; when omitted, payers land on /apps/<appId>.
        returnTo: body.returnTo,
      });

      return Response.json(result, {
        status: result.status === "error" ? 400 : 200,
      });
    },
  };
}

Use an idempotency key that is stable for the same payer and business operation. Retries with the same key return the existing request instead of creating a second charge request. Use a new key for a genuinely new purchase.

Handle the result in your UI

From your React app, call your API route and branch on the result status:

import { fetchAppApi } from "@rome-os/app-web-sdk";

async function buySummary(reportUrl: string) {
  const response = await fetchAppApi("summaries", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      reportUrl,
      returnTo: window.location.pathname + window.location.search,
    }),
  });
  const result = await response.json();

  if (result.status === "pending_consent" && result.authorizationUrl) {
    window.location.href = result.authorizationUrl;
    return;
  }

  if (result.status === "queued") {
    // Payment has already settled for this idempotency key; show queued state.
    return;
  }

  if (result.status === "declined") {
    // The visitor declined this idempotent request.
    return;
  }

  if (result.status === "error") {
    throw new Error(result.error);
  }
}

pending_consent is the normal first response for a new request. Send the user to authorizationUrl so they can pay or decline. queued usually means a retry hit an already-settled request and the action dispatch is queued. declined means the matching request was declined. error covers invalid args, an unknown actionName, missing visitor favor auth, or a favor service failure.

After the visitor pays or declines, Rome Cloud sends them back to the returnTo page (or /apps/<appId> when you didn't pass one) with favor=settled or favor=declined appended as a query param — read it on mount to show the outcome.

Dispatch behavior

When payment settles, Rome marks the request queued. The owner's instance syncs queued favor requests, claims one, runs the requested action with the validated args, and reports success or action failure back to Rome Cloud.

The dispatched action is a normal Rome action. Return { status: "ok", data } for success. Returning { status: "error", error }, throwing, or returning a suspended result marks the favor dispatch as failed.

On this page