Skip to content

Create a Rovo Action

Rovo actions are functions that a Rovo agent can call when answering a user. Each action maps to a named export in TypeScript and a declaration in manifest.yml. This guide walks through adding one end-to-end.

Where things live

forge/raise-jira/
  manifest.yml                  ← action declarations + function mappings + agent configs
  src/
    agent/
      actions.ts                ← all action handler implementations
      actions.test.ts           ← action tests (Vitest)
    remote/
      platform-client.ts        ← platformFetch() — authenticated client for raise-server
    confluence/
      skills.ts                 ← findSkillPage() — Confluence helper (used by find-skill)

Step 1 — Declare the action in manifest.yml

Open forge/raise-jira/manifest.yml. There are two places to edit.

1a. Add an action: entry

Under the action: section (after the last action, around line 310):

- key: my-new-action
  name: My new action
  function: fn-my-new-action
  actionVerb: GET
  description: One sentence describing what this action does and when Rovo should call it.
  inputs:
    requiredParam:
      title: Required Param
      type: string
      required: true
      description: What this param means (e.g., a Jira issue key like RAISE-1606)
    optionalParam:
      title: Optional Param
      type: string
      required: false
      description: An optional filter or modifier.

key uses kebab-case. function is fn- + the same key. actionVerb is always GET for read operations.

1b. Add a function: entry

Under the # Agent actions comment in the function: section (around line 386):

- key: fn-my-new-action
  handler: agent/actions.myNewAction

handler format is agent/actions.<exportedFunctionName> — the part after the dot must exactly match the export name in actions.ts.

Step 2 — Implement the handler in actions.ts

Open forge/raise-jira/src/agent/actions.ts. Add a named export at the bottom.

Signature

export const myNewAction = async (payload: Record<string, unknown>): Promise<unknown> => {
  // 1. Extract and validate inputs
  // 2. Call backend (platformFetch or asApp().requestJira)
  // 3. Return a plain object
};

All action handlers share this exact signature. Do not use the Resolver class — that pattern is for UI modules only. Rovo requires direct named exports.

Response conventions

Return a plain object. Use these shapes consistently:

Situation Shape
Success with data { found: true, ...fields }
Not found (404) { found: false, message: 'Human-readable explanation.' }
Input validation error { error: 'Tell the user what is missing.' }
Backend error { error: \Failed to ...: \${result.status}` }`

Never throw. Return { error: ... } instead — Rovo passes these back to the agent's context.

Calling raise-server (platformFetch)

Use platformFetch for calls to the raise-server backend. It handles RSK auth automatically.

import { platformFetch } from '../remote/platform-client';

export const myNewAction = async (payload: Record<string, unknown>): Promise<unknown> => {
  const { issueKey } = payload as { issueKey: string };

  if (!issueKey) {
    return { error: 'Please provide an issue key (e.g., RAISE-1606).' };
  }

  interface MyResponse {
    field_one: string;
    field_two: number;
  }

  const result = await platformFetch<MyResponse>(`/api/v2/my-endpoint/${encodeURIComponent(issueKey)}`);

  if (!result.ok) {
    if (result.status === 404) {
      return { found: false, message: `No data found for ${issueKey}.` };
    }
    return { error: `Failed to fetch: ${result.status}` };
  }

  return {
    found: true,
    fieldOne: result.data.field_one,
    fieldTwo: result.data.field_two,
  };
};

platformFetch returns { ok: boolean; status: number; data: T }. On 204 (no body), data is {}.

Calling Jira directly (asApp().requestJira)

For Jira API calls (issue search, transitions, agile boards), import from @forge/api:

export const myNewAction = async (payload: Record<string, unknown>): Promise<unknown> => {
  const { projectKey } = payload as { projectKey: string };

  if (!projectKey) {
    return { error: 'Please provide a project key (e.g., RAISE).' };
  }

  const { route, asApp } = await import('@forge/api');

  const response = await asApp().requestJira(
    route`/rest/api/3/search/jql`,
    {
      method: 'POST',
      headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
      body: JSON.stringify({ jql: `project = "${projectKey}"`, maxResults: 10, fields: ['key', 'summary'] }),
    },
  );

  if (!response.ok) {
    return { error: `Jira search failed: ${response.status}` };
  }

  const data = (await response.json()) as { issues: Array<{ key: string }> };
  return { issues: data.issues.map((i) => i.key) };
};

Use route\...`` (tagged template) for all Jira paths — it handles URL encoding and is required by Forge's permission model.

Step 3 — Register the action with an agent

Back in manifest.yml, find the agent that should offer this action. There are two:

  • raise-coach (lines ~98–167) — delivery health advisor; uses assessment and coaching actions
  • rai-for-rovo (lines ~169–231) — governance and project management; uses memory, graph, backlog actions

Under the agent's actions: list, add your action key:

- key: rai-for-rovo
  ...
  actions:
    - find-skill
    - query-memory
    - query-graph
    - manage-backlog
    - save-governance
    - my-new-action    # ← add here

An action can appear in multiple agents.

Step 4 — Write a test

Open forge/raise-jira/src/agent/actions.test.ts. Follow the existing pattern — one describe block per action, with beforeEach mocks.

import { myNewAction } from './actions';

// At the top of the file, alongside other vi.mock calls:
vi.mock('../remote/platform-client', () => ({
  platformFetch: vi.fn(),
}));

// In the test file:
describe('myNewAction', () => {
  let mockPlatformFetch: ReturnType<typeof vi.fn>;

  beforeEach(() => {
    vi.clearAllMocks();
    const { platformFetch } = vi.mocked(await import('../remote/platform-client'));
    mockPlatformFetch = platformFetch as ReturnType<typeof vi.fn>;
  });

  it('returns data for a valid input', async () => {
    mockPlatformFetch.mockResolvedValue({
      ok: true,
      status: 200,
      data: { field_one: 'hello', field_two: 42 },
    });

    const result = (await myNewAction({ issueKey: 'RAISE-1' })) as Record<string, unknown>;
    expect(result.found).toBe(true);
    expect(result.fieldOne).toBe('hello');
  });

  it('returns not-found for 404', async () => {
    mockPlatformFetch.mockResolvedValue({ ok: false, status: 404, data: {} });
    const result = (await myNewAction({ issueKey: 'RAISE-99' })) as Record<string, unknown>;
    expect(result.found).toBe(false);
    expect(typeof result.message).toBe('string');
  });

  it('returns error when input is missing', async () => {
    const result = (await myNewAction({})) as Record<string, unknown>;
    expect(typeof result.error).toBe('string');
  });
});

Run tests:

cd forge/raise-jira
npm test

Step 5 — Deploy

cd forge/raise-jira
npm run build
forge deploy

forge deploy uploads the new function binding. After deploy, the action is immediately available to the registered agents in Rovo.

Custom UI — first-time install in static/raise-app

If you also touch the Custom UI and need to rebuild it, pnpm install inside static/raise-app will fail with:

[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: esbuild, style-dictionary, ...
Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts.

Run this once to unblock it:

cd forge/raise-jira/static/raise-app
pnpm approve-builds && pnpm install
pnpm run build

After that, go back to forge/raise-jira and run forge deploy as usual. Subsequent pnpm install runs in that directory will work without the approve step.

Summary of the three-point checklist

  1. manifest.yml — one action: entry + one function: entry + agent reference
  2. src/agent/actions.ts — one named export matching the handler path
  3. src/agent/actions.test.ts — success, 404, and validation-error cases

Common mistakes

Using Resolver instead of a named export. The Resolver class only works for UI module resolvers (issue panels, project pages). Rovo agent actions need direct export const name = ...resolver.define(...) is ignored by the Forge agent runtime.

handler name mismatch. The part after the dot in handler: agent/actions.myFn must exactly match the TypeScript export name. A typo here causes a silent failure at runtime — the action appears registered but never executes.

Forgetting encodeURIComponent. Any user-supplied value interpolated into a platform URL path must be encoded. Jira keys like RAISE-1 are safe, but never assume.

Returning a thrown error instead of { error: ... }. Uncaught exceptions in Forge actions surface as opaque failures in Rovo. Always catch and return structured errors.