Actions
Define typed operations you can call — the main thing code and agents run.
Agent-readable version: Markdown · llms.txt
An action is a typed operation a Rome app exposes that you can call. Actions
are the main thing other code calls: API handlers call them, actions call each
other, agents call them through LLM tool-use, and routines fire them on a
trigger. Each action has a public contract (action.yaml) and an implementation
(index.ts) that returns one result object, whose kind is told apart by its
status field.
This page covers how to define an action, the ActionResult it returns, how to
call other actions, and how previews and approvals work.
Defining an action
The action.yaml contract
Each action lives in its own folder under src/actions/<name>/ and lists its
details in action.yaml. The agent planner reads these fields when it decides
whether to pick the action.
name: dream # Local action name; definitions never contain `:`
type: custom # Almost always `custom`
description: Concise summary # The agent sees this — write it clearly
entry: ./index.ts # Optional; defaults to ./index (resolves the compiled .js or the .ts source)
complexity: simple|moderate|complex # Call cost
speed: fast|moderate|slow
reliability: low|medium|high
sideEffects: read-only|write # Honest metadata the agent reads when planning a call
favorRequirement: # Optional — charge favors before visitor-triggered dispatch
amount: 25
title: Summarize a report
summary: Create a concise summary from a report URL.
displayFields:
- label: Report
from: $.reportUrlFor an app whose manifest has id: dream, callers refer to this action as
dream:dream. The definition keeps only the local name; every runtime
reference includes the owning App ID.
These fields map straight onto the ActionConfig interface exported by
@rome-os/app-runtime:
export interface ActionConfig {
name: string;
type: "system" | "custom";
description: string;
entry?: string;
complexity: "simple" | "moderate" | "complex";
speed: "fast" | "moderate" | "slow";
reliability: "high" | "medium" | "low";
sideEffects: "read-only" | "write";
requiresApproval?: boolean;
cancellable?: boolean;
webhook?: boolean;
favorRequirement?: {
amount: number;
title: string;
summary?: string;
displayFields?: Array<{ label: string; from: string }>;
};
}The checks are strict: unknown fields in action.yaml are rejected at
pack/install. Only use fields documented here. Set sideEffects: write honestly
— it is the metadata the agent and the action catalog see when deciding whether
a call touches the outside world. If a call should pause for the guardian's
sign-off before running, set requiresApproval: true (see
approvals below).
favorRequirement marks this action as a favor-gated top-level action that an
authenticated hosted-app visitor can pay for through ctx.favors.requestAction.
The recipient is always the hosted app owner. Nested action calls do not create
additional favor charges. A favor-required action cannot also set
requiresApproval: true.
createAction(config, deps)
The implementation file must export an action factory — createAction(config, deps) is the recommended form; the loader also accepts a single create*Action
function or a function default export. The factory returns an Action: an
object with the same config passed back, an optional inputSchema, an
execute method, and an optional preview.
import type {
Action,
ActionConfig,
ActionResult,
AppActionRuntimeDeps,
} from "@rome-os/app-runtime";
export function createAction(
config: ActionConfig,
deps: AppActionRuntimeDeps,
): Action {
return {
config,
inputSchema: {
type: "object",
properties: {
prompt: { type: "string", description: "..." },
},
required: ["prompt"],
},
async execute(args): Promise<ActionResult> {
// Business logic
return { status: "ok", data: { /* ... */ } };
},
};
}The deps argument is AppActionRuntimeDeps<T>, a box of dependencies that
always holds appContext and adds any extras T your app defines (for example
an agentRunner):
export type AppActionRuntimeDeps<TShared = Record<string, unknown>> = TShared & {
appContext: RomeAppContext;
};To pass in your own dependencies, widen the factory signature with your own type:
interface Deps {
agentRunner: AgentRunnerInterface;
}
export function createAction(
config: ActionConfig,
deps: AppActionRuntimeDeps<Deps>,
): Action {
const { agentRunner, appContext } = deps;
// ...
}The Action shape:
export interface Action {
config: ActionConfig;
inputSchema?: Record<string, unknown>;
execute(args: Record<string, unknown>): Promise<ActionResult>;
preview?(args: Record<string, unknown>): PreviewPayload | Promise<PreviewPayload | undefined>;
}defineAction with a Zod schema
execute gets args typed only as Record<string, unknown>. The args come
from a model tool call and are not checked anywhere before this point. The
hand-written createAction form above leaves you to check them yourself.
defineAction takes that off your hands: one Zod schema is the source of truth
for the model-facing JSON Schema, the runtime check, and the static input type
handed to execute.
The loader still looks for a factory export, so wrap defineAction in
createAction and pass through the config the loader parsed from
action.yaml — a bare export const demo = defineAction({...}) is never
discovered:
import {
defineAction,
z,
type Action,
type ActionConfig,
type AppActionRuntimeDeps,
} from "@rome-os/app-runtime";
export function createAction(
config: ActionConfig, // parsed from action.yaml — don't write your own literal
deps: AppActionRuntimeDeps,
): Action {
return defineAction({
config,
schema: z.object({ x: z.string() }),
execute: async (input) => {
// `input` is typed `{ x: string }` — already checked.
return { status: "ok", data: { echoed: input.x } };
},
});
}defineAction accepts:
function defineAction<S extends z.ZodType>(spec: {
config: ActionConfig;
schema: S;
execute: (input: z.infer<S>) => Promise<ActionResult>;
preview?: (input: z.infer<S>) => PreviewPayload;
}): Action;It builds inputSchema from the Zod schema with z.toJSONSchema, dropping the
$schema key so the result is a clean model-facing schema:
// action.inputSchema for z.object({ x: z.string() })
{
type: "object",
properties: { x: { type: "string" } },
required: ["x"],
}When the model-facing schema must differ from the checked type (hand-tuned
conditional schemas, internal-only fields), build the Action object directly
with createAction instead of using defineAction.
Checking at the edge
defineAction checks the args at the edge and fails safe — it never makes an
unchecked cast into the handler. Both entry points reject bad args the same way,
and neither throws:
executereturns anerrorresult and skips the handler entirely.previewreturns an"Invalid input"card instead of throwing.
Good args are parsed before they reach the handler, and unknown keys are dropped:
Input to execute (schema { x: string }) | Result |
|---|---|
{} | { status: "error", error: "Invalid input for demo: ..." }; handler not run |
{ x: "hi", extra: "dropped" } | handler runs with { x: "hi" } — extra removed |
const action = defineAction({
config,
schema: z.object({ x: z.string() }),
execute: async () => ({ status: "ok" }),
});
const result = await action.execute({}); // missing x
// result.status === "error"
// result.error matches /Invalid input for demo/Because the check never throws, a bad call shows a readable error (or an "Invalid input" approval card) instead of a missing card or an uncaught exception.
The ActionResult object
Every action returns an ActionResult, a union told apart by its status
field. This one type is used everywhere a call crosses a line — agent↔action
tool results, app↔app runAction, webhook invocation records — so the states
are one union instead of separate flags.
export type ActionResult<T = unknown> =
| { status: "ok"; data?: T }
| { status: "error"; error: string }
| { status: "pending_approval"; approval: PendingApproval }
| { status: "pending_interaction"; interaction: PendingInteraction }
| { status: "handoff"; handoff: Handoff }
| { status: "place_widget"; placement: PlaceWidget };| Status | Meaning | Suspends the turn? |
|---|---|---|
ok | Completed. Optional data payload. | No |
error | A turned-down request the caller can act on. | No |
pending_approval | Paused, waiting for guardian approval; the action body has not run. | Yes |
pending_interaction | Paused on an inline guardian input drawn by the app's component. | Yes |
handoff | Paused; control passes to another agent in a child session. | Yes |
place_widget | Completed and placed a web widget on the workspace. | No |
Check the status first
Always check status before you read data or error — each field exists only
on its own variant.
const result = await appContext.runAction("notes:notes_list", { limit: 20 });
if (result.status !== "ok") {
const error =
result.status === "error"
? result.error
: `notes:notes_list returned ${result.status}`;
return { status: "error", error };
}
const items = result.data; // safe hereThe paused variants carry data the host uses to drive the screen:
pending_approval→{ approvalId, actionName, description }. When the guardian approves, core runs the action for real; the result comes back separately (a new agent turn / webhook poll update).pending_interaction→{ appId, promptText, render: { kind: "inline", componentId, props? } }. The chat host shows the app's own component (listed inapp.yamland registered with the web SDK'sdefineComponent) as an assistant block. Control never leaves the calling agent.handoff→{ appId, promptText, agentName?, payload?, handback?, handbackHint? }. The host opens a separate child session the guardian works in; the parent thread is locked until control comes back.place_widget→{ appId, route?, params? }. Fire-and-forget: the widget mounts on the side, the agent is told it worked, and it keeps going on the same turn. Only works on a webchat turn.
For all paused and widget variants, promptText and off-webchat behavior
matter: on a messaging channel or inside a subagent there is no screen to mount
on, so the calling agent passes the text along as prose (or, for place_widget,
is told the widget couldn't be shown).
ActionInvocationError vs a domain error
There are two separate ways a call can fail, and you must not mix them up:
- A turned-down request → an
{ status: "error", error }result. The action ran but said no for a reason the caller can act on ("routine name already taken"). You handle it by checkingstatus. - An infrastructure failure → a thrown
ActionInvocationError. A handler that throws, args/results that can't be turned into JSON, a dead worker, or an unknown action show up here — never as anerrorresult.
export class ActionInvocationError extends Error {
constructor(
readonly actionName: string,
readonly code: ActionInvocationErrorCode,
message: string,
) { /* ... */ }
}
export type ActionInvocationErrorCode =
| "not_found"
| "unserializable"
| "handler_error"
| "worker_failure";This is the one failure shape of runAction, no matter which process the caller
or callee ran in, so app code never has to care how the call traveled.
Cancellation is the one exception: it is a runtime control signal, not a failure
the app handles.
Calling other actions
appContext.runAction
appContext.runAction(canonicalId, args) is the only way to call an action
— your own, another app's, or a platform built-in. canonicalId is always
<app-id>:<local-name>, including for actions owned by the caller. By default
it makes a nested call and waits for one ActionResult.
export interface RomeAppContext {
app: RomeAppDefinition;
controller: unknown;
db: AppDbContext;
log: Logger;
repositories: AppRuntimeRepositories;
favors: AppFavorCapability;
runAction(
name: string, // canonical <app-id>:<local-name>
args: Record<string, unknown>,
options?: { detached?: false },
): Promise<ActionResult>;
runAction(
name: string, // canonical <app-id>:<local-name>
args: Record<string, unknown>,
options: { detached: true },
): Promise<{ executionId: string }>;
listRoutines(): Promise<Routine[]>;
}favors is how an app charges a visitor before an action runs — see
Favor Request Actions.
const history = await appContext.runAction("system:fetch_channel_history", {
channel: "discord",
windowHours: 24,
});
if (history.status !== "ok") {
const reason =
history.status === "error" ? history.error : `returned ${history.status}`;
return {
status: "error",
error: `system:fetch_channel_history failed: ${reason}`,
};
}
// use history.dataThe default call, including an explicit { detached: false }, belongs to the
caller's execution tree. It shares the caller's rootExecutionId, and
cancelling or tearing down the caller also cancels the nested action. You
must await it. Never write this:
// Incorrect: the caller can finish and lose ownership while the child runs.
void appContext.runAction("review-app:review_pull_request", args);When the work must outlive the caller, dispatch it as a detached root:
const { executionId } = await appContext.runAction(
"review-app:review_pull_request",
args,
{ detached: true },
);detached: true creates a new root execution: its executionId and
rootExecutionId are the returned executionId, it has no parent execution,
and cancelling the caller does not cancel it. The await waits only until the
main process accepts the dispatch; it does not wait for the action result.
Use the returned ID to inspect or cancel that independent execution. A failure
after acceptance is recorded on the new execution rather than thrown back into
the caller. Detached dispatch is lifecycle-independent, not a durable queue:
a main-process restart can still interrupt it.
Calls don't care how they travel; JSON rules apply
runAction doesn't care how the call travels: the runtime decides whether the
callee runs in-process or in a worker, and your code sees the same behavior
either way. Because a call may cross a process line, args and result.data
travel as JSON:
- A
Datearrives as an ISO string. undefinedfields are dropped.- No shared references — the callee gets a copy.
A failed call rejects with ActionInvocationError; an action that ran but
said no resolves with { status: "error", error }. Check status for the
second; let the first bubble up (or catch it on purpose).
Calling platform built-ins
The same call shape reaches platform built-ins. Platform actions live in the
system app namespace:
// Reuse another app's agent — always go through `system:summon`.
await appContext.runAction("system:summon", { agentName, prompt, sessionId });
// Register a scheduled or watched-event routine.
await appContext.runAction("system:create_routine", {
name: "daily-dream",
trigger: {
type: "schedule",
tzid: "UTC",
localTime: "03:00",
tzMode: "fixed",
rrule: "FREQ=DAILY",
},
actionName: "dream:dream",
args: {},
});Other built-ins you can reach this way include system:fetch_channel_history
and system:send_message — and actions shipped by first-party apps, such as
dream:get_action_logs. Note the agent's action allowlist governs
which actions the agent can invoke as tools; action-to-action runAction
calls are not allowlist-gated — they are attributed to your app via
callerAppId.
Previews and approvals
preview() payloads
An action can add an optional preview(args) that returns a PreviewPayload
describing what the action would do. It is used to build the approval cards
shown to the guardian and the exact render on routine-draft cards. Write it as
a pure function of its args — no I/O: the type allows a promise (the
runtime may load the implementation lazily), but the payload should come
straight from the args, never from a side effect.
export type PreviewPayload =
| {
kind: "sensitive_message";
channel: string;
threadId: string;
text: string;
reason?: string;
}
| {
kind: "generic";
title: string;
summary: string;
fields?: { label: string; value: string }[];
};With defineAction, the renderer gets parsed, checked input, and is skipped on
bad args (replaced by an "Invalid input" generic card):
export const sendNote = defineAction({
config, // requiresApproval: true in action.yaml
schema: z.object({ channel: z.string(), threadId: z.string(), text: z.string() }),
execute: async (input) => { /* ... */ return { status: "ok" }; },
preview: (input) => ({
kind: "sensitive_message",
channel: input.channel,
threadId: input.threadId,
text: input.text,
}),
});requiresApproval and the approval flow
Whether a call is held for approval comes down to one flag: set
requiresApproval: true in action.yaml and the engine pauses every call to
the action for the guardian's sign-off. sideEffects does not gate
approval — it is honest metadata the agent and the action catalog read when
weighing a call. Declare write truthfully, and add requiresApproval when
the action needs a human in the loop.
When a call is held, the action returns a pending_approval result — the
action body has not run:
export interface PendingApproval {
approvalId: string;
actionName: string;
description: string;
}The flow:
- The caller calls an action with
requiresApproval: true. - The engine holds the call and it resolves with
{ status: "pending_approval", approval }. The host shows an approval card built from the action'spreview()payload (falling back to prose when there is nopreview). - On guardian approval, core runs the action for real; the result comes back separately (a new agent turn or webhook poll update).
Because preview() is what fills the approval card, keep it accurate and free of
side effects — it is the guardian's only view of what is about to happen.