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

# Build an experience

> Compose operations from published integrations into widgets, pages and workflows, with no credentials or integration code of your own.

An **experience** is a package that composes operations from integrations. It is the way to build something that spans several services (a content desk over a CMS, a news feed and a social network, say) without copying any of their code or holding any of their credentials.

An integration owns a connection to one service and exposes it as **operations**: read-only tools and actions that change something. An experience owns the screens and workflows. It names the integrations it needs, pins the operations it may call, and reaches them only through those pinned operations.

| | Integration | Experience |
| - | - | - |
| Holds credentials, OAuth, webhooks, egress | Yes | No |
| Calls a vendor's API | Yes, in its handlers | Never; it calls operations |
| Ships widgets and pages | Widgets for its own data | Widgets, pages and workflow templates over any integration's operations |
| Declared with | `defineIntegration` | `defineExperience` |
| `ekanos.json` `kind` | `"integration"` (the default) | `"experience"` |

> [!NOTE]
>
> **"Experience" also names something else in the Ekanos app.** There it means
> one of Ekanos's own built-in capabilities, such as Agents or Workflows, that a
> platform operator switches on for a Source (see [Sources and
> teams](https://sdk.ekanos.dev/concepts/sources-and-teams.md)). An experience you build here is a
> package of your own, composed from integrations. The two share a word and
> nothing else.

## Scaffold one

In an [Ekanos repo](https://sdk.ekanos.dev/guides/one-repo-many-packages.md), add an experience next to the integrations it uses:

```bash theme={null}
ekanos new experience content-desk
```

On its own, outside a repo:

```bash theme={null}
ekanos init --kind experience --slug content-desk
cd content-desk
pnpm install
```

Either way you get an `ekanos.json`, `src/experience.ts`, a starter widget, an empty `contracts/` folder for pinned contracts, an empty `fixtures/` folder, and a `package.json` that depends on `@ekanos/sdk` and `@ekanos/ui` and never on an integration.

## Declare the integrations in ekanos.json

An experience lists the integrations it depends on in `ekanos.json`, not in code, so the CLI and the host can read them without running anything.

```json theme={null}
{
  "kind": "experience",
  "slug": "content-desk",
  "entry": "src/experience.ts",
  "contractsDir": "contracts",
  "integrations": [
    {
      "name": "acme-cms",
      "version": "0.2",
      "operations": ["tool/list_posts", "tool/get_post", "action/create-draft"]
    },
    {
      "name": "acme-news",
      "version": "0.1",
      "operations": ["tool/get_headlines"],
      "required": "optional"
    }
  ]
}
```

**name** `string` *required*

The integration's own slug. There is no alias: code addresses the integration
by this name.

**version** `string` *required*

The integration's **compat line** you pin, such as `"0.2"` (for a 0.x
integration the line is `0.<minor>`; from 1.0 it is the major). See [Operation
contracts](https://sdk.ekanos.dev/guides/operation-contracts.md).

**operations** `string[]` *required*

The operations this experience may call, as `tool/<name>` or `action/<name>`.
The long form `acme-cms@0.2/tool/list_posts` is also accepted. Anything not
listed is a compile error in your code and a refusal at run time.

**required** `'required' | 'optional'`

Defaults to `"required"`.

**modes** `('team' | 'invoker' | 'user')[]`

Which connection levels the experience can run at. Defaults to `["team"]`.

An experience lists at least one integration, and each integration once. The other `ekanos.json` fields are `kind`, `slug` and `entry` (all required), `contractsDir`, `source`, `host`, [`availability`](https://sdk.ekanos.dev/cli/publishing-a-repo#availability), and `allowImports`. The full set is in [ekanos.json](https://sdk.ekanos.dev/cli/ekanos-json.md).

### Pin an integration

Don't write the entries by hand. `ekanos add` resolves the integration's contract, writes the entry, records the digest in `ekanos.lock.json`, copies the contract into `contracts/`, and regenerates the types:

```bash theme={null}
ekanos add acme-cms@0.2 --ops tool/list_posts,action/create-draft
```

With no `--ops`, a new integration gets every read operation; writes are opt-in. Inside a repo, an integration that is a package of the same repo is found automatically (run `ekanos contract` in it first so it has a `contract.json`). Outside a repo, `ekanos add` looks in the session's catalog and the published packages, and you can point it at a folder of contracts with `--from-dir <folder>` or at an integration project with `--from-path <dir>`.

`ekanos sync` re-resolves every pin, accepts a new contract digest within the pinned line, and rewrites the lock and types; `ekanos sync --check` only reports drift.

> [!WARNING]
>
> **Coming from `uses`?** Earlier versions declared dependencies under `"uses"`
> with an alias, and `ekanos.local.json` under `"slots"`. Both are still read
> for one version with a deprecation warning. Run `ekanos sync` to rewrite them
> to `integrations`; entries that used two aliases for one integration must be
> merged first.

## defineExperience

`src/experience.ts` default-exports the definition:

```tsx theme={null}
import { defineExperience } from '@ekanos/sdk/experience';

import { DraftQueueWidget } from './widgets/draft-queue-widget';
import { ReviewPage } from './pages/review-page';
import { morningDigest } from './workflows/morning-digest';

export default defineExperience({
  slug: 'content-desk',
  name: 'Content desk',
  description: 'Draft posts from the news and review them in one place.',
  sidebar: { section: 'content', icon: 'newspaper', label: 'Content' },
  widgets: [
    {
      id: 'draft-queue',
      title: 'Draft queue',
      integrations: ['acme-cms', 'acme-news'],
      width: 'half',
      component: DraftQueueWidget,
      isCollapsible: true,
    },
  ],
  pages: [
    {
      id: 'review',
      path: 'review',
      title: 'Review queue',
      integrations: ['acme-cms'],
      component: ReviewPage,
    },
  ],
  workflows: [morningDigest],
  reviewQueue: {
    readsOnly: true,
    sections: [
      { id: 'cms-posts', integration: 'acme-cms', read: 'tool/list_posts' },
    ],
  },
});
```

* `slug` must be kebab-case and equal the `slug` in `ekanos.json`.
* A widget or page has no integration props. Its `component` is a plain React component that calls the hooks below and draws with `@ekanos/ui` and the `Widget.*` compound API, the same as an integration widget. `width` is `'compact'`, `'half'` or `'full'`.
* `integrations` on a widget or page names the integrations it calls. Naming one that is not in `ekanos.json` is a compile error, and `ekanos check` re-verifies it.
* `sidebar` is a declaration only; Ekanos decides how it is drawn.
* `reviewQueue` is reads-only. Each section's `read` must be a read operation of its integration.
* Keys that belong to an integration (`oauth`, `egress`, `storage`, `webhooks`, `tools`, `actions`, `connection`, `activation`, `events`, `agentEvents`, `schedules`, `secrets`) are compile errors on an experience, and `defineExperience` throws on them from untyped code.

## Call operations

Types come from the pinned contracts. After `ekanos add`, `src/.ekanos/integrations.d.ts` is generated, and every call below is checked against it: the integration name, the operation, its input and its output. Commit that file so typechecking works in CI without a network.

### Reads: useOperation

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

import { useOperation } from '@ekanos/sdk/experience/client';
import { Widget } from '@ekanos/sdk/components';

// Assumes acme-cms's list_posts returns { items: [...] }.
export function DraftQueueWidget() {
  const posts = useOperation('acme-cms', 'tool/list_posts', { limit: 10 });

  return (
    <Widget.DataState>
      <Widget.Card>
        <Widget.Header context="Latest posts" />
        <Widget.Active>
          <Widget.Body>{posts.data?.items.length ?? 0} posts</Widget.Body>
        </Widget.Active>
        <Widget.Loading variant="stat" />
        <Widget.Error />
      </Widget.Card>
    </Widget.DataState>
  );
}
```

`useOperation(integration, op, input?, options?)` returns `{ data, error, isPending, isFetching, refetch }`. `error` is `{ reason, message }`. Options are `enabled`, `staleTime` (default 30 seconds) and `refetchInterval`. Only read operations type-check here; a write is a compile error.

### Writes: useOperationMutation

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

import { useOperationMutation } from '@ekanos/sdk/experience/client';

export function SaveDraftButton({ title }: { title: string }) {
  const createDraft = useOperationMutation('acme-cms', 'action/create-draft');

  return (
    <button
      type="button"
      disabled={createDraft.isPending}
      onClick={async () => {
        const result = await createDraft.mutate({ title });
        if (!result.ok) console.warn(result.reason, result.message);
      }}
    >
      Save draft
    </button>
  );
}
```

`mutate(input)` resolves with `{ ok: true, data }` or `{ ok: false, reason, message }`. A refusal is data, never a throw. On success it refreshes that integration's reads (`invalidate: 'integration'`, or `'all'`, or `false` in the third argument's options).

### On the server: operation()

```ts theme={null}
import { operation } from '@ekanos/sdk/experience/server';

const result = await operation('acme-cms', 'tool/list_posts', { limit: 20 });
if (result.ok) console.log(result.data.items.length);
```

A server call is on the `rsc` channel, so a write is refused there with `write_refused_on_channel`. Reads work as usual.

### Why a call can be refused

Every call returns the same shape on failure, `{ ok: false, reason, message }`. The reasons are `operation_not_declared`, `operation_not_in_contract`, `write_refused_on_channel`, `write_not_approved`, `mode_not_permitted`, `binding_missing`, `invalid_input`, `input_too_large`, `deadline_exceeded`, `execution_failed`, `output_invalid`, `output_too_large`, `rate_limited` and `product_unavailable`. A name that is not one of your integrations is refused with a code of its own that predates the rename and still carries the old word for an integration. The harness and the host use the same strings.

## Writes are held for approval

A write never runs silently. When a widget calls `useOperationMutation(...).mutate(...)`, the runtime asks a person to approve it first, and the promise resolves once that is settled: `write_not_approved` if the person rejected it. In the harness, held writes collect in an approvals tray on the experience view, reading like "1 write waiting for approval: acme-social draft\_post". Workflows state the gate explicitly, with an approval step.

## Workflow templates

A workflow template is data: a list of steps that Ekanos's workflow engine runs on a schedule or by hand. `defineWorkflowTemplate` builds one, and `step` makes the steps.

```ts theme={null}
import { defineWorkflowTemplate, ref, step } from '@ekanos/sdk/experience';

export const morningDigest = defineWorkflowTemplate({
  id: 'morning-digest',
  name: 'Morning digest',
  schedule: '0 7 * * 1-5',
  build: () => [
    step.tool('headlines', 'acme-news', 'tool/get_headlines', {}),
    step.ai('draft', {
      instructions: 'Write a short post from the top headline.',
      outputSchemaRef: 'digest_draft',
    }),
    step.approval('approve_cms', {
      summary: 'Create the CMS draft "{{draft.title}}"?',
    }),
    step.tool(
      'cms_draft',
      'acme-cms',
      'action/create-draft',
      { title: ref('draft.title') },
      { after: 'approve_cms' },
    ),
  ],
});
```

* `id` is kebab-case; step ids are lowercase with underscores, and `trigger`, `item`, `loop` and `run` are reserved.
* `schedule` is a five-field cron expression. Omit it for a template that is started by hand. `scope` is `'account'` (the default) or `'user'`.
* `step.tool(id, integration, op, args, options?)` is typed from the pinned contract. Whether the step reads or writes is **read from the contract**, not authored, so a template cannot call a write as if it were a read. Pass `ref('step.field')` where an argument comes from an earlier step.
* Steps run in the order of `build()`; each follows the previous one unless you pass `{ after: 'step_id' }` (or an array of ids).
* `step.ai(id, { instructions, outputSchemaRef })` is a model step. In the harness it is answered from `fixtures/ai/<outputSchemaRef>.json`, so add that file to run the workflow locally.
* `step.approval(id, { summary, approverScope? })` holds the run until a person decides. `approverScope` is `'account'` (the default) or `'user'`.

`build()` runs at `ekanos build`, never at run time, and serializes to the workflow engine's own format.

## Fixtures and the harness

`ekanos dev` shows the experience at `/experiences/<name>` in a repo, or `/experience` in a project of its own, with Summary, Widgets, Workflows, Review queue and Run log tabs, plus a Connect checklist. By default every integration answers from fixtures: `fixtures/<integration>/<kind>-<op>.<default|empty|error>.json` if present, otherwise the example in the pinned contract. Connect an integration and its reads run live with its stored credentials; its writes still go through approval. See [Repo mode](https://sdk.ekanos.dev/harness/repo-mode.md) for the connection and live-or-fixtures controls.

## Check and build

```bash theme={null}
ekanos check   # pins, generated types, boundary, fixtures, definition, typecheck
ekanos build   # dist/ and manifest.json
```

`ekanos check` fails when:

* a pin names a compat line the resolver does not have, or an operation the contract lacks;
* the generated types are stale (run `ekanos sync`);
* the experience imports integration code. The boundary check scans `package.json`, every import under `src/`, and the bundle. An experience may import `react`, `react-dom`, `@ekanos/sdk`, `@ekanos/ui`, `zod`, `@tanstack/react-query`, `next-intl`, `react-hook-form` and `@hookform/resolvers`, plus anything you list under `allowImports`. Anything else, and any integration package, fails;
* the definition does not match `ekanos.json`;
* `ekanos.local.json` is tracked by git (it is per-developer).

`@ekanos/sdk/eslint/experience` is a lint preset for faster feedback in the editor.

## Publishing

`ekanos publish --dry-run` gates the experience, builds it, and packs it. A real publish of an experience is **not supported by the host yet**; today only the dry run works. See [Publishing from a repo](https://sdk.ekanos.dev/cli/publishing-a-repo.md). A local override of an integration (an `ekanos.local.json` pointing outside the repo) refuses a publish, because what you ran is not what a host would resolve.

## Next steps

* [One repo, many packages](https://sdk.ekanos.dev/guides/one-repo-many-packages.md)
* [Operation contracts](https://sdk.ekanos.dev/guides/operation-contracts.md)
* [Repo mode in the harness](https://sdk.ekanos.dev/harness/repo-mode.md)


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