# Actions
URL: /docs/building-apps/capabilities/actions
Markdown: /docs/building-apps/capabilities/actions.md
Description: Define typed operations you can call — the main thing code and agents run.

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 [#defining-an-action]

### The `action.yaml` contract [#the-actionyaml-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.

```yaml
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`:

```ts
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](#requiresapproval-and-the-approval-flow) 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)` [#createactionconfig-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`.

```ts
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`):

```ts
export type AppActionRuntimeDeps<TShared = Record<string, unknown>> = TShared & {
  appContext: RomeAppContext;
};
```

To pass in your own dependencies, widen the factory signature with your own type:

```ts
interface Deps {
  agentRunner: AgentRunnerInterface;
}

export function createAction(
  config: ActionConfig,
  deps: AppActionRuntimeDeps<Deps>,
): Action {
  const { agentRunner, appContext } = deps;
  // ...
}
```

The `Action` shape:

```ts
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 [#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:

```ts
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:

```ts
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:

```ts
// 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 [#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                            |

```ts
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 [#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.

```ts
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 [#check-the-status-first]

Always check `status` before you read `data` or `error` — each field exists only
on its own variant.

```ts
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 [#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.

```ts
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 [#calling-other-actions]

### `appContext.runAction` [#appcontextrunaction]

`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`.

```ts
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](/docs/building-apps/capabilities/favor-request-actions).

```ts
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:

```ts
// 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:

```ts
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 [#calls-dont-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 [#calling-platform-built-ins]

The same call shape reaches platform built-ins. Platform actions live in the
`system` app namespace:

```ts
// 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 [#previews-and-approvals]

### `preview()` payloads [#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.

```ts
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):

```ts
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 [#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**:

```ts
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.