Skip to main content
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.
“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). 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, add an experience next to the integrations it uses:
On its own, outside a repo:
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.
string
required
The integration’s own slug. There is no alias: code addresses the integration by this name.
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.
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' | 'optional'
Defaults to "required".
('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, and allowImports. The full set is in ekanos.json.

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:
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.
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:
  • 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

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

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()

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.
  • 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 for the connection and live-or-fixtures controls.

Check and build

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