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: $.reportUrl

For 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:

  • execute returns an error result and skips the handler entirely.
  • preview returns 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 };
StatusMeaningSuspends the turn?
okCompleted. Optional data payload.No
errorA turned-down request the caller can act on.No
pending_approvalPaused, waiting for guardian approval; the action body has not run.Yes
pending_interactionPaused on an inline guardian input drawn by the app's component.Yes
handoffPaused; control passes to another agent in a child session.Yes
place_widgetCompleted 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 here

The 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 in app.yaml and registered with the web SDK's defineComponent) 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 checking status.
  • 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 an error result.
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.data

The 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 Date arrives as an ISO string.
  • undefined fields 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:

  1. The caller calls an action with requiresApproval: true.
  2. The engine holds the call and it resolves with { status: "pending_approval", approval }. The host shows an approval card built from the action's preview() payload (falling back to prose when there is no preview).
  3. 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.

On this page