> ## 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

> Widget-invoked server handlers: declare actions, call them with useIntegrationAction, handle IntegrationActionError, and call vendors from the browser with useIntegrationFetch.

Actions are deterministic server handlers a widget calls directly: a user clicks Save, the handler runs with the same `ctx` every other handler gets, and the widget handles the result. There is no assistant and no natural-language step, so an action has no `parameters` and no `proposes` classification, unlike a [tool](/sdk/surfaces/tools).

For the local dev loop (fixtures mode, live mode, the live-mode confirmation) see [Harness actions](/harness/actions).

## Declare an action

Actions live in a record on `defineIntegration()`, keyed by a kebab-case name. `input` (and the optional `output`) are real zod schema instances, not JSON-schema stand-ins.

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

const createRecordInput = z.object({ name: z.string().min(1) });
const createRecordOutput = z.object({ id: z.string() });

export type CreateRecordInput = z.infer<typeof createRecordInput>;
export type CreateRecordOutput = z.infer<typeof createRecordOutput>;

export const integration = defineIntegration({
  // ...
  egress: ['api.acme.example'],
  actions: {
    'create-record': {
      description: 'Create a record from the widget form.',
      input: createRecordInput,
      output: createRecordOutput, // optional: validates what the handler returns
      async handler(ctx, input) {
        const response = await ctx.fetch('https://api.acme.example/records', {
          method: 'POST',
          body: JSON.stringify(input),
        });
        if (!response.ok) throw new Error('Create failed');
        return response.json();
      },
    },
  },
});
```

| Field | Required | Meaning |
| - | - | - |
| `description` | yes | One sentence on what the action does |
| `input` | yes | Schema the posted input is validated against before the handler runs |
| `output` | no | Schema the handler's resolved value is validated against. Without it the value passes through unvalidated. |
| `handler` | yes | `(ctx, input) => Promise<result>` |
| `outputExample` | no | An example of the resolved value |

Action names are validated at import time. A name that is not kebab-case fails with `Action names are kebab-case, e.g. "create-record" — the host route and useIntegrationAction() address the handler by this exact string.`

### Typing

`@ekanos/sdk` is published separately from your package, so it cannot see your zod schema from the browser side. You supply `TInput` and `TOutput` yourself at the call site, typically by exporting `z.infer` types next to the schema (as above) and importing them into the widget. The hook treats the wire payload as `unknown` at runtime and checks only the response envelope.

### `PartnerActionDeclaration`

The declaration type is exported from `@ekanos/sdk/integration`, so you can name it when you export a handler to share with a tool:

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

export const createRecord: PartnerActionDeclaration<
  typeof createRecordInput,
  typeof createRecordOutput
> = {
  description: 'Create a record from the widget form.',
  input: createRecordInput,
  output: createRecordOutput,
  async handler(ctx, input) {
    // `input` is inferred as { name: string }
    return { id: 'rec_1' };
  },
};
```

With zod 4 support (0.4.0), `PartnerActionDeclaration`'s schema generics are constrained by Standard Schema (`StandardSchemaV1`, exported from `@ekanos/integration-schema`), so a schema from either zod major satisfies them. Inside your own module the types infer precisely; in the erased `actions` record on the definition, an action handler's `input` is `any`, so existing handlers like `handler: (ctx, input) => createRecord(input)` still compile.

## Call an action from a widget

`useIntegrationAction<TInput, TOutput>(slug, actionName, options)` from `@ekanos/sdk/hooks` is a TanStack Query mutation that posts to `POST /api/integrations/<slug>/actions/<actionName>` with the body `{ accountId, input }`.

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

import {
  IntegrationActionError,
  useIntegrationAction,
} from '@ekanos/sdk/hooks';

import type { CreateRecordInput, CreateRecordOutput } from './integration';

export function NewRecordButton({ accountId }: { accountId: string }) {
  const createRecord = useIntegrationAction<
    CreateRecordInput,
    CreateRecordOutput
  >('acme-crm', 'create-record', { accountId });

  return (
    <>
      <button
        disabled={createRecord.isPending}
        onClick={() => createRecord.mutate({ name: 'Acme' })}
      >
        Create
      </button>
      {createRecord.error && <p role="alert">{createRecord.error.message}</p>}
    </>
  );
}
```

`mutate` and `mutateAsync` take the action's input directly. A resolved mutation's `data` is `TOutput`.

Options:

| Option | Meaning |
| - | - |
| `accountId` | The account the action runs against. Must be one the caller belongs to. |
| `baseUrl` | Overrides the base path (default `/api/integrations`). For harnesses and tests; production code leaves it alone. |

`integrationActionUrl(slug, actionName, { baseUrl? })` builds the exact URL the hook requests. It is exported from `@ekanos/sdk/hooks` only, because a test double needs to recognize the URL.

## Errors

A rejected mutation's `error` is always an `IntegrationActionError`: never a bare `Error`, a `Response`, or a raw fetch exception.

```ts theme={null}
new IntegrationActionError(message, { status, code, issues? });
```

| Property | Meaning |
| - | - |
| `message` | Human-readable text from the route (or from the hook, for the two transport codes) |
| `status` | HTTP status. `0` means the request never reached the server. |
| `code` | One of the codes below |
| `issues` | Your own zod issues. Present only for a 400 `invalid_input`, so a form can map failures onto fields. |

| Code | Status | Meaning |
| - | - | - |
| `action_not_found` | 404 | Unknown action name |
| `invalid_input` | 400 | Body is not JSON, or the input failed the `input` schema |
| `rate_limited` | 429 | Production rate limit |
| `handler_error` | 500 | The handler threw, or its result failed the `output` schema |
| `network_error` | `0` | Assigned by the hook: the request itself failed (offline, DNS, aborted) |
| `invalid_response` | response status | Assigned by the hook: a response came back but its body was not the envelope |

The response envelope is `{ success: true, data }` on success and `{ success: false, error, code, issues? }` on failure.

```tsx theme={null}
if (createRecord.error?.code === 'invalid_input') {
  for (const issue of createRecord.error.issues ?? []) {
    // map onto form fields
  }
}
```

### Showing your own message: `PublicActionError`

Every throw from a handler is logged server-side and collapses to a generic `"Action failed."` under `handler_error`, because a thrown error can embed vendor response bodies or credentials. To show your own message to the user verbatim, throw `PublicActionError` from `@ekanos/sdk/integration`:

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

async handler(ctx, input) {
  const existing = await ctx.fetch(`https://api.acme.example/records?name=${encodeURIComponent(input.name)}`);
  if ((await existing.json()).length > 0) {
    throw new PublicActionError('That record already exists.');
  }
  // ...
}
```

The route surfaces `message` as-is under the same `handler_error` code and status. Only put text in it that is safe for the end user to read.

## Calling a vendor from the browser

Actions run on the server. When a widget has to call a vendor API from the browser, use `useIntegrationFetch()` from `@ekanos/sdk/hooks` instead of bare `fetch()`. Outside a provider it is a plain `globalThis.fetch` passthrough. The harness mounts `IntegrationFetchProvider` and, in live mode, checks those requests against your `egress` list; production runs no egress check on the client path, so keep credentials server-side and call the vendor from an action or tool through `ctx.fetch`. See [Live mode](/harness/live-mode) and [Known limitations](/sdk/known-limitations).

`useActivationData()` and `IntegrationActivationDataProvider` give a widget activation data, but only the harness mounts a provider today. Outside one the hook returns `null`, so do not depend on it for production behavior.

In widget tests, `createMockFetchProvider()` from `@ekanos/sdk/testing` returns a provider component that answers `useIntegrationFetch()` calls from the same fixtures as `createMockContext()`. See [Testing](/sdk/testing).

## Troubleshooting

* `action_not_found` — the second argument to `useIntegrationAction()` does not match a key in `actions`.
* `invalid_input` — the input failed the `input` schema; read `error.issues`.
* `handler_error` with `Action failed.` — the handler threw. Throw `PublicActionError` if the user should see the message, and read server logs for the real error. In the harness, the `ekanos dev` terminal prints it.

## Next steps

* [Harness actions](/harness/actions) — run actions locally in fixtures or live mode
* [Tools](/sdk/surfaces/tools) — the assistant-facing counterpart
* [Widgets](/sdk/surfaces/widgets) — the `Widget.*` compound API


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