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:
favorRequirementis 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
- Add
favorRequirementto the top-level action'saction.yaml. - Implement and list that action in
app.yaml. - From an authenticated app API handler, call
ctx.favors.requestAction(...). - Send the returned
authorizationUrlto the browser when the result ispending_consent. - 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-reportThe 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: $.toneThe 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.