> ## Documentation Index
> Fetch the complete documentation index at: https://sdk.ekanos.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Actions

> Invoke declared action handlers from a widget's Save button.

See [Actions](/sdk/surfaces/actions) for declaring actions and the full `useIntegrationAction()` reference.

Actions let a widget call a declared server handler directly—no assistant, no natural language—with input validation and fixed error codes. The harness serves these handlers locally in both fixtures and live mode.

## Overview

`useIntegrationAction()` from `@ekanos/sdk/hooks` makes a real `fetch()` to `POST /api/integrations/<slug>/actions/<actionName>`. The harness route answers it by running your declared handler against a mock (fixtures) or live context.

```tsx theme={null}
import { useIntegrationAction } from '@ekanos/sdk/hooks';

function SaveButton({ accountId }: { accountId: string }) {
  const { mutate } = useIntegrationAction('my-integration', 'update-settings', {
    accountId,
  });

  return (
    <button onClick={() => mutate({ theme: 'dark' })}>Save Settings</button>
  );
}
```

Your definition declares the action in the `actions` record, keyed by a kebab-case name:

```ts theme={null}
// In your definition
actions: {
  'update-settings': {
    description: 'Save the display theme',
    input: z.object({ theme: z.enum(['light', 'dark']) }),
    output: z.object({ saved: z.boolean() }),
    handler: async (ctx, input) => {
      await ctx.storage.account.set('theme', { value: input.theme });
      return { saved: true };
    },
  },
},
```

The `theme` key must be declared in your definition's `storage` schemas.

## Fixtures mode

In fixtures mode, the route validates the posted input against the action's `input` schema, then runs the handler against a mock context built with `createMockContext()` from your definition and `triggerMocks`, one per integration and fixture variant.

```ts theme={null}
triggerMocks: {
  storage: { account: { theme: { value: 'light' } } },
  secrets: { account: { theme_api: 'test_key' } },
  fetchHandlers: [
    {
      match: 'https://api.acme.com/theme',
      respond: () => Response.json({ saved: true }),
    },
  ],
},
```

`ctx.fetch` is answered from `triggerMocks.fetchHandlers` first, then from the active variant's `fixtures`, and never the real network. The context lives in dev-server memory, so state accumulates across action calls on the same variant; each variant has its own context.

## Live mode

In live mode, the route runs the handler against the same **server-held context** that OAuth and live webhooks/schedules use—its `ctx.fetch` reaches your real vendor under the declared `egress`, with admin credentials from `.ekanos/local-secrets.json` and whatever `onTokens` or the activation flow stored.

Live mode requires your entry's `live` block. An integration with no `live` block stays on fixtures for actions.

## Session confirmation

The first action call on an integration **while the toolbar is set to Live API raises a blocking browser confirmation** before anything is sent:

```
"<slug>" wants to run "<actionName>" against REAL, live <slug> data — this is not a fixture.

Choose OK to allow this action, and every other action on this integration, for the rest of this browser session.
```

Accepting covers every later action call on that integration for the rest of the browser session (tracked in `sessionStorage`, so it survives a reload and ends when the tab closes). Declining throws `LiveActionNotConfirmedError` and the request is never sent. Fixtures mode never prompts.

<Note>
  This is a harness convenience against accidental clicks during iteration, not
  a production security boundary. Production runs exactly one mode, with no
  toggle and no per-session confirmation.
</Note>

## Response codes

The route answers the same fixed `code`s as the production host route. `useIntegrationAction()`'s rejected mutation surfaces them as an `IntegrationActionError` (exported from `@ekanos/sdk/hooks`) with `message`, `status`, `code`, and `issues?`:

| Code | Status | Meaning |
| - | - | - |
| `action_not_found` | 404 | Unknown action name |
| `invalid_input` | 400 | Body is not JSON, or input fails the `input` schema; `issues` holds the zod issues |
| `rate_limited` | 429 | Production rate limit. The harness does not enforce a rate limiter, so it never returns this |
| `handler_error` | 500 | The handler threw, or its result failed the `output` schema |
| `network_error` | `0` | Assigned by the hook: the request never reached the server |
| `invalid_response` | response status | Assigned by the hook: the response was not a valid envelope |

## Handler errors

If your handler throws, the route returns `handler_error` with the generic message `Action failed.`. In production, a handler that throws `PublicActionError` has its own message returned verbatim under `handler_error` instead:

```ts theme={null}
import { PublicActionError } from '@ekanos/sdk/integration';

handler: async (ctx, input) => {
  const user = await ctx.fetch('https://api.acme.com/user/' + input.userId);

  if (!user.ok) {
    throw new PublicActionError(
      'Could not load user. Check the ID and try again.',
    );
  }

  return { saved: true };
};
```

Read it from the mutation's error:

```tsx theme={null}
const action = useIntegrationAction('my-integration', 'update-settings', {
  accountId,
});

// after action.mutate(...) rejects
if (action.error?.code === 'handler_error') {
  console.error(action.error.message);
}
```

<Note>
  The harness route always answers a thrown handler with `Action failed.`,
  including for `PublicActionError`. For debugging it adds a dev-only `devError`
  field (message and stack) to the response body and prints the real error in
  the terminal running `ekanos dev`; production never returns `devError`.
</Note>

## Example: widget with actions

```tsx theme={null}
'use client';

import type { IntegrationComponentProps } from '@ekanos/sdk';
import {
  fetchIntegrationStorage,
  useIntegrationAction,
} from '@ekanos/sdk/hooks';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@ekanos/ui/button';
import {
  Select,
  SelectTrigger,
  SelectValue,
  SelectContent,
  SelectItem,
} from '@ekanos/ui/select';

export function ThemeWidget({ accountId }: IntegrationComponentProps) {
  const { data: stored } = useQuery({
    queryKey: ['my-integration', 'theme', accountId],
    queryFn: () =>
      fetchIntegrationStorage('my-integration', { accountId, keys: ['theme'] }),
  });
  const theme = stored?.theme as { value?: string } | undefined;

  const { mutate, isPending } = useIntegrationAction(
    'my-integration',
    'update-settings',
    { accountId },
  );

  return (
    <div>
      <Select defaultValue={theme?.value || 'light'}>
        <SelectTrigger>
          <SelectValue />
        </SelectTrigger>
        <SelectContent>
          <SelectItem value="light">Light</SelectItem>
          <SelectItem value="dark">Dark</SelectItem>
        </SelectContent>
      </Select>

      <Button onClick={() => mutate({ theme: 'dark' })} disabled={isPending}>
        Save
      </Button>
    </div>
  );
}
```

## Troubleshooting

### action\_not\_found

The action name doesn't match any key in your definition's `actions`. Check the second argument to `useIntegrationAction()`.

### invalid\_input

The input failed the `input` schema. The error's `issues` array holds the zod issues.

### handler\_error

The handler threw, or its result failed the `output` schema. In the harness, read `devError` in the response body or the `ekanos dev` terminal for the real error.

### Live mode confirmation appears again

The confirmation is per integration and per browser tab session. A new tab, or a different integration, asks again.

### Every action returns 500 handler\_error in fixtures mode (older harness)

Before `@ekanos/harness` 0.4.0, a registry that declared a host-route fixture such as `GET /api/integrations/<slug>/storage` made every action call fail with `handler_error` before the handler ran, because the server-side context tried to compile that fixture. 0.4.0 skips host-route fixtures on the action path (an action's `ctx.fetch` can only address a vendor origin anyway). Upgrade with `ekanos upgrade`.

### No recorded response error in fixtures mode

A handler's `ctx.fetch` call matched no `triggerMocks.fetchHandlers` entry and no fixture for the active variant. Record a fixture (see [Fixtures](/harness/fixtures)).

### Action works in fixtures but fails in live

Check the Local OAuth panel or `.ekanos/local-secrets.json` for missing admin secrets, and the terminal running `ekanos dev` for the handler's error.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.