“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: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 inekanos.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"].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:
--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.
defineExperience
src/experience.ts default-exports the definition:
slugmust be kebab-case and equal thesluginekanos.json.- A widget or page has no integration props. Its
componentis a plain React component that calls the hooks below and draws with@ekanos/uiand theWidget.*compound API, the same as an integration widget.widthis'compact','half'or'full'. integrationson a widget or page names the integrations it calls. Naming one that is not inekanos.jsonis a compile error, andekanos checkre-verifies it.sidebaris a declaration only; Ekanos decides how it is drawn.reviewQueueis reads-only. Each section’sreadmust 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, anddefineExperiencethrows on them from untyped code.
Call operations
Types come from the pinned contracts. Afterekanos 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()
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 callsuseOperationMutation(...).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.
idis kebab-case; step ids are lowercase with underscores, andtrigger,item,loopandrunare reserved.scheduleis a five-field cron expression. Omit it for a template that is started by hand.scopeis'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. Passref('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 fromfixtures/ai/<outputSchemaRef>.json, so add that file to run the workflow locally.step.approval(id, { summary, approverScope? })holds the run until a person decides.approverScopeis'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 undersrc/, and the bundle. An experience may importreact,react-dom,@ekanos/sdk,@ekanos/ui,zod,@tanstack/react-query,next-intl,react-hook-formand@hookform/resolvers, plus anything you list underallowImports. Anything else, and any integration package, fails; - the definition does not match
ekanos.json; ekanos.local.jsonis 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.