@ekanos/harness
v0.18.0
Published
The Ekanos integration dev harness — every surface of an integration rendered in real Ekanos chrome, from fixtures, with no Supabase or auth; and, in live mode, a real localhost OAuth loop over your own dev-app credentials.
Readme
@ekanos/harness
The Ekanos integration dev harness: every surface of an integration rendered in
real Ekanos chrome — the actual dashboard grid, the actual Widget.* compound
components, the actual design tokens — from fixtures, with no Supabase, no auth
and no network.
Docs: sdk.ekanos.dev/harness/overview.
New here? Start with the @ekanos/sdk README — it is the authoring
reference for everything you declare (widgets, tools, webhooks, schedules,
OAuth, storage, egress). This document covers only the local dev harness that
renders those surfaces.
You do not normally install this by hand. ekanos dev scaffolds a shell around
it and adds it to your package.json.
pnpm --filter <your project> exec ekanos dev- Why it is a package
- The registry is injected, never imported
- The registry —
harness.config.ts - Fixtures
- Live mode — real requests to your own API
- Triggers — running webhooks, schedules and OAuth locally
- What ends up in the browser bundle
- Exports
- The shell contract
The surfaces this renders — widgets, the tile, the activation form, and the
webhook / schedule / OAuth declarations behind Triggers — are documented in the
@ekanos/sdk README. This one covers the harness's own contract.
Why it is a package
So that a harness bugfix reaches you as a version bump you pick up with
pnpm up, with zero edits to any file you own. The harness is a dependency,
not code you copied.
| Path | Who owns it |
| --------------------------------------------------------------- | ---------------------------------------------------------- |
| harness.config.ts | you — the registry, and the only file you have to edit |
| your integration source | you |
| the generated app/**, styles/globals.css, next.config.mjs | the CLI; regenerated on upgrade |
| @ekanos/harness | us |
The registry is injected, never imported
A package inside node_modules cannot reach a file in your project, so nothing
here imports your config. The shell passes your array down instead:
// app/harness-shell.tsx — generated
'use client';
import { RootLayout } from '@ekanos/harness/app';
import { harnessIntegrations } from '~/harness.config';
export default function HarnessShell(props: { children: React.ReactNode }) {
return <RootLayout integrations={harnessIntegrations} {...props} />;
}RootLayout mounts a context provider; every route reads the registry back out
of it and takes its own [slug] from useParams(). That is why the generated
route files are one line each:
export { WidgetsPage as default } from '@ekanos/harness/routes';RootLayout is the client boundary of the whole harness, and has to be: a
HarnessIntegration holds live React component references and MCP run
functions, which cannot be serialized across a server/client edge.
The registry — harness.config.ts
This is the only file you edit. It exports an array of HarnessIntegration,
one entry per integration, and each entry lights up four tabbed surfaces at
/<slug> — widgets, tile, activation and triggers — plus a
single-widget page at /<slug>/widgets/<widgetId>, which the widget grid links
into for isolating one widget.
import { defineHarnessConfig } from '@ekanos/harness/config';
import { acmePayments } from './harness/acme-payments';
export const harnessIntegrations = defineHarnessConfig([acmePayments]);defineHarnessConfig() is an identity function. It exists for the inference —
nothing else.
HarnessIntegration
| Field | Required | What it does |
| --------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------- |
| slug | yes | URL segment, and the id you would ship as your product slug |
| name, description | yes | Header chrome and the index page |
| widgets | yes | HarnessWidget[] — may be empty |
| fixtures | no | Partial<Record<FixtureVariant, HttpFixture[]>> — the recorded HTTP exchanges, per variant. This is how data gets in |
| tile | no | ComponentType<MarketplaceTileProps> — the marketplace card |
| activationForm | no | ComponentType<ActivationFormProps>, rendered with inline so it does not open a dialog |
| definition | no | Your validated defineIntegration() output. Powers Triggers |
| triggerMocks | no | Seeds for the Triggers surface's mock context |
| live | no | Opt in to real third-party requests |
HarnessWidget
| Field | Required | What it does |
| ----------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| id | yes | Stable id; the URL segment on /<slug>/widgets/<widgetId> |
| title | yes | Rendered in the widget header chrome |
| component | yes | ComponentType<IntegrationComponentProps> |
| width | no | 'half' (default) or 'full'. Runs of half balance across two columns |
| isCollapsible | no | Renders the collapse button. Off unless set |
| isPinnable | no | Reaches WidgetContext; nothing draws a pin — see below |
| aiFooterEnabled | no | Renders the "Ask about this" AI footer bar. Off unless set |
| seeds | no | Partial<Record<FixtureVariant, FixtureSeed[]>> — react-query cache seeds for this widget, per variant. The fast path, not the default; see Fixtures |
All three chrome flags are opt-in and behave as the type reads: omit one and
you get nothing. The harness applies the host's own default (?? false) in its
copy of the host's grid, so what renders here is what renders in production.
One of them, though, renders nothing even when you do set it:
isPinnable is plumbed end to end with no consumer. It reaches
WidgetContext as meta.isPinnable, the current value is state.pinned, and
actions.togglePinned() works — but no shipped Widget.* component draws a
pin control.
This is faithful, not a harness gap. The real dashboard does not render a pin in widget chrome either; it drives pinning from its own loader and customize panel, outside the widget. So if you want a pin inside your widget, it is yours to build, in the harness and in production alike:
const ctx = use(WidgetContext);
{
ctx?.meta.isPinnable && (
<button onClick={ctx.actions.togglePinned}>
{ctx.state.pinned ? 'Unpin' : 'Pin'}
</button>
);
}Derive the widget list — don't write it twice
HarnessWidget repeats id, component, isCollapsible, isPinnable and
aiFooterEnabled from components.widgets[] in your defineIntegration()
output, renaming name to title. Writing both by hand is two declarations of
the same thing with nothing detecting drift, so don't:
import { harnessWidgetsFromDefinition } from '@ekanos/harness/registry';
widgets: harnessWidgetsFromDefinition(acmeDefinition, {
'acme-payments-summary': { width: 'full' },
}),The definition supplies id, title, component and the chrome flags. The
overrides map supplies what is genuinely harness-only — width, and per-widget
seeds if you use them — and is keyed by widget id, so id is not a field
inside the override value. An override wins over the definition where both
speak.
Your HTTP fixtures are not in here. They sit on the integration
(HarnessIntegration.fixtures), one level up, because a URL is not owned by
one widget.
Pass your definition straight in, generic and all — unlike the definition:
field on the same entry, this does not need asHarnessDefinition() (the
generic-erasing wrapper the Triggers section below explains). That
asymmetry is real and easy to trip on: the field stores the definition in a
heterogeneous array and so needs the generic erased, while the helper only
reads it.
Two properties worth knowing, both deliberate:
- An override key that matches no declared widget throws, naming the key and listing the ids that do exist. A typo'd id would otherwise produce a widget rendering with none of its overrides and no complaint — the same silent-drift class the helper exists to kill.
- A chrome flag your definition does not declare is omitted, not defaulted, so it still lands on the host's default rather than one the helper invented.
Call this unless you have a reason not to. The manual form is fully
supported and widgets is still an ordinary HarnessWidget[] — the helper
just returns one, so you can spread it and add entries. That is the reason to
reach for the manual form: a scratch widget or a variant you are trying out
that is deliberately not in your definition yet. The harness is exactly where
you should be able to try something before declaring it.
widgets: [
...harnessWidgetsFromDefinition(acmeDefinition, { /* … */ }),
{ id: 'scratch-experiment', title: 'Scratch', component: Experiment },
],Why any of this is needed: the harness never reads
definition.components.widgets. It touches your definition in four places —
the egress cross-check, twice in the Triggers surface, and once on the
activation page for capabilities/permissions. Widgets are not among them,
so on this surface the registry is the only declaration and the definition's
widget flags are inert. The helper is how you make the definition the source
anyway.
One thing the helper does not unify, because the two are genuinely
different: widget sizing is two unrelated systems.
components.widgets[].layouts (lg/md/sm grid rectangles, in the
definition) is what the real dashboard reads. HarnessWidget.width
('half' | 'full', in the registry) is what the harness reads. Setting
layouts changes nothing here and setting width changes nothing in
production, so set both and expect neither to validate the other.
Your widgets are rendered with HARNESS_ACCOUNT_ID, HARNESS_SOURCE_ID and
HARNESS_ACCOUNT_SLUG, all exported from @ekanos/harness/registry. They are
plausible UUIDs rather than sentinels like 'preview-mode', so a widget that
validates the shape of its accountId is happy. Nothing reads them — there is
no database.
Fixtures
In fixtures mode the harness answers every request from recorded HTTP
exchanges and refuses the network. It patches globalThis.fetch itself, so
this holds however your widgets fetch — react-query, SWR, a bare useEffect, a
promise started in render and read with use(). There is no data layer to opt
into and nothing to wire.
You declare those exchanges on the integration, keyed by variant:
import type { HarnessIntegration } from '@ekanos/harness/registry';
import { TidepoolForecast, TidepoolNow, TidepoolNowEmpty } from './fixtures';
import { ForecastWidget, NowWidget } from './widgets';
const API = 'https://api.tidepool.example.com';
export const tidepool: HarnessIntegration = {
slug: 'tidepool',
name: 'Tidepool',
description: 'Tide readings and forecast.',
fixtures: {
default: [
{ request: `GET ${API}/v1/tides/current`, response: TidepoolNow },
{ request: `GET ${API}/v1/tides/forecast`, response: TidepoolForecast },
],
empty: [
{ request: `GET ${API}/v1/tides/current`, response: TidepoolNowEmpty },
{
request: `GET ${API}/v1/tides/forecast`,
response: { station: '9414290', entries: [] },
},
],
error: [
{ request: `GET ${API}/v1/tides/current`, status: 503 },
{ request: `GET ${API}/v1/tides/forecast`, status: 503 },
],
},
widgets: [
{ id: 'tidepool-now', title: 'Tide Now', component: NowWidget },
{
id: 'tidepool-forecast',
title: 'Tide Forecast',
component: ForecastWidget,
},
],
};They sit on the integration, not the widget, because a URL is not owned by
one. The same GET /v1/payouts may answer one widget, a second widget, and a
webhook handler on the Triggers surface — and it should be written once. The
Triggers surface reads this same list, so ctx.fetch inside a webhook or
schedule handler resolves against the fixtures your widgets already use.
There are exactly three variants, switched from the dev toolbar:
| Variant | For |
| --------- | ---------------------------------------------------------------- |
| default | the populated happy path — declare this one |
| empty | the zero-rows state |
| error | the failure state, as ordinary responses with a failure status |
empty and error are optional, and an omitted variant is not a fallback
to default: the list for that variant is empty, so every request is refused
and each widget shows the refusal. That is deliberate — a variant that silently
served the happy path would look like your empty state working.
Switching the variant only reaches a widget that re-fetches. The harness
tears down and rebuilds the whole subtree on a switch, so a widget fetching in
an effect or through react-query asks again and gets the new recording. A widget
that starts its promise during render and caches it at MODULE scope — the
obvious way to stop use() re-firing on every render — does not: the module
outlives the remount, so it keeps serving the first variant's answer while the
toolbar says error. Measured on our own tidepool example, whose forecast
widget is exactly that shape: three switches, one forecast request, happy-path
data showing under every variant.
The fix is where the promise is created, not how. Moving the cache into the
component that calls use() — const [p] = useState(() => fetch(…)) right above
the use(p) — looks right and is worse: a render that suspends is discarded
before it commits, so the state it created is thrown away and the initializer
runs again on every retry. Measured on the same three switches, that turns one
request into twenty-six, and the widget still displays correctly the whole time.
Create the promise in the non-suspending parent and pass it across the Suspense boundary:
function ForecastWidget() {
const [request] = useState(() => fetchForecast(FORECAST_URL));
return (
<Suspense fallback={<Loading />}>
<ForecastBody request={request} />
</Suspense>
);
}The parent commits, so its state survives; the child suspends and retries against the same promise. It still starts during render, so nothing about the archetype changes.
This is a widget-side trap rather than a fixtures one — the recording was swapped correctly; nothing asked for it — but it looks exactly like a fixture that did not take, so it is worth recognising. Note what does NOT distinguish the three states: the rendered output. Stuck, storming and correct all look identical on screen, and only a request count tells them apart.
HttpFixture — the request line
interface HttpFixture {
request: string; // '<METHOD> <url-or-path>'
response?: unknown; // body; JSON unless already a string
status?: number; // default 200 with a body, 204 without
headers?: Record<string, string>;
}request is a method and a target separated by a space. The method is
case-insensitive and must be one of GET, HEAD, POST, PUT, PATCH,
DELETE, OPTIONS. The target is one of three forms:
| Form | Example | Resolves against |
| ------------ | ------------------------------------- | ------------------------------------------- |
| Absolute URL | GET https://api.acme.com/v1/payouts | itself — always unambiguous |
| Path only | GET /v1/payouts | your defineIntegration({ egress }) origin |
| Host route | GET /api/integrations/acme/summary | the harness's own origin |
The path-only form exists so you do not maintain a second baseUrl beside your
egress declaration, and it is allowed only when your definition declares
exactly one origin. With none or with two, the harness cannot make a
defensible guess, so compileFixture throws while the surface renders — naming
your fixture and printing the absolute form to write instead. It never guesses
and it never skips a fixture it cannot parse.
/api/… is the exception to both rules: it is the host-route namespace. A
first-party integration's widgets call /api/integrations/<slug>/… rather than
the vendor directly, because that is where the credential lives. The harness
serves no backend, so those would 404; a fixture is the only thing that can
answer one, and it is answered like any other.
Matching, in the order the rules apply:
- Method and origin must be equal.
- Path segments must agree in count.
:parammatches exactly one segment,*matches the rest (including nothing). So/v1/payoutsdoes not match/v1/payouts/42, but/v1/payouts/:idand/v1/payouts/*both do. - Query is a subset match, or ignored entirely. If your request line has a
?, every param in it must be present on the real request with the same value — so you can pin the two that matter and ignore the other nine. If it has no?, query is not consulted at all. That is what makesGET https://api.acme.com/v1/payoutsanswer a request carrying a page cursor, a locale and an API key. - First declaration wins. Write the specific fixture above the general one.
The response side has defaults chosen so the common cases are one line.
{ request: 'DELETE /v1/thing/:id' } is a complete fixture: no body, so 204. A
response that is a string is sent as-is with text/plain; anything else is
JSON-serialized with application/json. status overrides the default, which
is how an error variant is written — { request: 'GET /v1/payouts', status: 503 }
is an ordinary recorded response rather than a special code path.
One constraint comes from the shell rather than from fixtures:
harness.config.ts is imported from both a server component and a client
component, so everything reachable from it must be importable in both
graphs. A fixture module carrying a 'use client' directive compiles and then
fails at request time with Attempted to call … from the server. Keep fixture
data and URL constants in a plain module with no directive; put hooks and
components in the client ones.
When there is no fixture for a request
The request is refused, not sent — that is the promise fixtures mode makes, and it is unconditional. Three things happen, and they are worth being able to recognise:
The console gets a line naming the URL, from the harness rather than from your code:
[harness:tidepool] refused GET https://api.tidepool.example.com/v1/tides/current?…Every third-party call is logged this way —
fixture,network(a vendor request in live mode) orrefused— so the console is the fastest place to see what a widget actually asked for versus what you recorded. A same-origin/api/…request logsfixtureorrefusedin EITHER toolbar mode: it is answered from your registry entry whether the switch says Fixtures or Live API, because the harness serves no backend either way. Only genuinely third-party traffic can lognetwork, and only in live mode.The
fetchcall rejects withNoRecordedResponseError, whose message quotes the fixture to paste:No recorded response for GET https://api.tidepool.example.com/v1/tides/current?…. Fixtures mode answers every request from your registry entry and never leaves the machine, so this call was refused rather than sent. Record it: fixtures: { default: [{ request: 'GET https://api.tidepool.example.com/v1/tides/current', response: /* … */ }], } To exercise the failure branch instead, give it a status: { request: 'GET https://api.tidepool.example.com/v1/tides/current', status: 403 } The query string is hidden above because it can carry credentials. It is left off the line to paste on purpose: a request with no `?` matches whatever query the widget sends. Add `?key=value` only to pin params you want matched. If this URL looks unfamiliar, log it from the code that builds it — the path and any pinned query params must match exactly.Where you see it depends on your own error handling, because it is an ordinary rejected
fetch. A react-query widget lands in its error state (the harness setsretry: false, so immediately rather than after three backoffs). A widget that starts a promise in render and reads it withuse()throws, and if it does not catch it itself the harness's per-widget error boundary renders the whole message in the card — one broken widget, not a broken page.
Credentials are redacted from what is printed. Any userinfo in the URL is
stripped and the entire query string is replaced with ?…, in the console line
and in the error alike, because an API key in a query param is exactly the thing
that ends up in a screenshot. The quoted request: line therefore has no query
on it — which is correct as written, since a fixture with no ? matches
whatever query your widget sends. Add ?key=value back only to pin a param you
want matched.
When the refused request was a host route, the quoted line is the path form
(GET /api/integrations/acme/summary) even though the URL above it is absolute.
That is deliberate, not an inconsistency: /api/… resolves against whatever port
this harness happens to run on, and quoting that back would pin your fixture to
a port that is ours and not yours. Paste it as printed.
A URL you do not recognise is usually the real answer. Log it from the code that builds it rather than guessing: the path and any pinned query params have to match exactly, and a trailing slash or an extra segment is enough to miss.
One thing is deliberately not intercepted: same-origin traffic outside
/api/. That is /_next/static, the document itself and the ?_rsc= payloads
client navigation fetches — the framework's own plumbing, none of it your
integration talking to an API.
The server render is a partial pass, not an exempt one. It matters because a
widget that starts its fetch during render and reads it with use() runs there
too. Every fixture is compiled for that pass except the /api/ host-route
shape, which is the only one that resolves against the harness's own origin —
and a server render has none. Absolute and path-only fixtures need no origin, so
they answer on the server exactly as they do in the browser.
A host-route request during a server render is therefore refused, with a message that says so and tells you there is nothing to change in your registry entry — the client render compiles that fixture and answers it, so what you see after hydration is the real result. If you need it answered on the server too, write the fixture and the widget's URL both absolute, at the cost of hardcoding the dev server's origin.
Same-origin traffic outside /api/ gets a third message, and it is the one
worth reading carefully: there is no fixture to write for it. The client passes
that traffic straight through as framework plumbing, so it is never refused in
the browser — only the server pass has nowhere to send it. If the request was
meant to reach a vendor, give it an absolute URL and it becomes an ordinary
fixture in both passes.
seeds — the react-query fast path
HarnessWidget.seeds writes straight into the react-query cache, so a widget
paints populated with no request at all:
widgets: [
{
id: 'acme-payments-summary',
title: 'Payments Summary',
component: AcmeSummaryWidget,
seeds: {
default: [{ queryKey: summaryKey, data: acmeSummaryFixture }],
empty: [{ queryKey: summaryKey, data: acmeSummaryEmptyFixture }],
error: [{ queryKey: summaryKey, error: new Error('Acme returned 503') }],
},
},
],A FixtureSeed is { queryKey, data } or { queryKey, error }. Set both and
error wins.
This is the fastest seam and the narrowest. It only works if your data layer
is @tanstack/react-query, and it addresses data by query key rather than by
what your vendor returns — so the same payload has to be written again, in a
different shape, for anything that is not a widget. Reach for it when you want a
specific widget to skip the request entirely; otherwise record the exchange.
Two rules if you do use it:
- Derive the key from the same factory your hook calls. Retyping a key array gives you a widget that silently renders empty when someone renames a key; importing the factory makes it a compile error.
- A seeded key never fetches, so an HTTP fixture behind it is unreachable. Seeding short-circuits the request. Use one or the other for a given key, not both.
There is one error a widget can hit that no HTTP fixture can answer:
MissingFixtureError, thrown when a query has neither a seed nor a queryFn of
its own. Nothing makes a request in that case, so there is nothing to record —
seed the key.
Finally, HarnessWidget.seeds was called fixtures before HTTP fixtures
existed. It was renamed rather than overloaded: two fields called fixtures one
level apart, meaning different things, is a trap worth spending a rename to
avoid.
Why the format is HTTP and not query keys
Both of the problems it fixes are ones you would otherwise hit on your first integration:
- You authored the same vendor data twice, in two shapes, with nothing
relating them. A seed takes
datain the shape your hook returns — domain-shaped, post-parse. AtriggerMocks.fetchHandlersentry returns aResponse, so it is wire-shaped. The shipped Acme example used to carry both: one export feeding the handler's JSON envelope, separate exports feeding the widget seeds. Change one and the other went stale silently, and then the widget and the webhook handler disagreed about the same account. - Only react-query users could express a fixture at all.
queryKeywas the only way to address one, so a partner fetching with SWR, in a server component with plainfetch, or from auseEffecthad an inert fixtures surface — the harness mocking a cache they did not have rather than the network they did. That was also the population whose requests reached the real internet, because the interception was mounted on the same seams the fixtures were.
Recording the HTTP boundary fixes both at once: one declaration in one shape,
serving the widget and the handlers alike, with no opinion about how your
components fetch. @tanstack/react-query is still a hard peer of @ekanos/sdk
— useActivateIntegration and useOAuthConnectionStatus are built on it, and
the harness mounts QueryClientProvider unconditionally so the activation
surface works — so you install it either way. You just no longer have to build
on it to write a fixture.
Live mode — real requests to your own API
The harness mocks Ekanos, not your vendor. Declare a live block and the
toolbar grows a fixtures ⇄ live switch: in live mode the harness stops seeding
your data keys, your widgets' own query functions run, and their requests reach
the real API. There is still no Supabase, no auth and no server actions in
either mode — but live mode is no longer browser-only. The generated shell
serves /api/integrations/<slug>/… itself (@ekanos/harness/api) over one
server-held context per integration: the real OAuth loop, the storage
bridge your widgets read, and a trigger route the Triggers and Activation
surfaces run handlers through. See "Local OAuth" below.
live: {
// No `egress` here: the harness reads it off `definition`.
seeds: [{ queryKey: settingsKey, data: acmeSettingsFixture }],
},No partner wiring required. As of harness 0.1.5, HarnessProviders mounts
@ekanos/sdk/hooks's IntegrationFetchProvider/IntegrationActivationDataProvider
unconditionally. A widget that calls useIntegrationFetch() gets back a fetch
that is already correctly gated for whichever mode the toolbar is in — the
fixtures-mode refusal or the live-mode allowlist — with nothing declared
beyond the live block above. The hand-written FetchProvider field still
works (see "The legacy seam" below), but a new integration does not need to
write one.
The guarantee: live mode enforces the egress your definition declares — not
a second list you maintain beside it. A request to an origin your definition
did not declare throws EgressDeniedError on your laptop, carrying the message
a reviewer would have seen at promotion. Flipping the toolbar to live is
therefore a real test of the declaration under review, not of a convenience
copy that happens to sit next to it. This is a harness convenience, not a
preview of production enforcement: the check runs one layer below
useIntegrationFetch(), at a patched globalThis.fetch that can verify an
origin and a redirect target the way a browser genuinely can — which is not
the same as what ctx.fetch does on the server (DNS pinning, real redirect
re-vetting), and production runs no egress check on the client path, at any
layer, today. See the "Network model" note in a scaffolded project's
AGENTS.md for the fuller statement partners see.
So when you set definition, do not set live.egress. Supplying both is a
hard error naming your slug, not a silent preference — a silent preference is
exactly how the two lists became able to disagree. The harness resolves the
allowlist from definition.egress centrally (resolveEgress), so there is only
one list and it is the one a reviewer reads.
live.egress survives for the one case with nothing to read: an entry with no
definition linked. There it is the only source, and findEgressMismatch —
which reports a live.egress wider than the definition's — is what still has
work to do. A definition declaring more than live mode exercises is fine; the
reverse is the fault, and while it stands the harness forces fixtures mode and
says so on the surface.
seeds are the keys that stay mocked in live mode. Ekanos-side state your
widgets read before they can call anything: a saved location, an account
preference, whatever your own host route would have returned. Everything not
listed runs its real query function. That one line is the whole boundary the
design draws: Ekanos mocked, vendor real.
An integration with no live block is fixtures-only; the toolbar control is
disabled for it and says why. One WITH a live block still opens in fixtures
mode unless it declares live: { default: 'live' } — reserve that for an
integration whose widgets only talk to the harness's own routes, so opening
it fires nothing at a vendor.
Knowing the mode, and the activation-data channel
Two things a live-mode widget routinely needs: which mode is actually running, and a credential to authenticate with.
For the credential, call useActivationData() from @ekanos/sdk/hooks. The
harness mounts IntegrationActivationDataProvider unconditionally, so this
returns whatever was last submitted to this integration's activation form —
null before any submission or after Disconnect — with no provider of your
own required. It is held in memory only: never written to localStorage,
a fixture, or the registry, and cleared on reload and on disconnect. It is a
dev-only convenience standing in for ctx.secrets, which is what production
actually hands your server-side handlers.
For the mode itself, call useHarnessDataMode() from @ekanos/harness/hooks.
This one has no SDK equivalent and stays harness-only — production runs
exactly one mode, so there is nothing for a host-side hook to report. It
returns the EFFECTIVE mode ('fixtures' | 'live'), already accounting for an
integration with no live block or a live.egress/definition mismatch (both
force 'fixtures', whatever the toolbar says):
import { useHarnessDataMode } from '@ekanos/harness/hooks';
import { useActivationData, useIntegrationFetch } from '@ekanos/sdk/hooks';
function useAcmeSummary(accountId: string) {
const fetch = useIntegrationFetch();
const mode = useHarnessDataMode();
const activationData = useActivationData<{ apiToken?: string }>();
// ...
}A hand-written FetchProvider — where one already exists — receives all
three (fetch, mode, activationData) as props too, no extra plumbing
required; see "The legacy seam" below for when writing one is still the
better call over the hooks above (and for the same token-threading example,
written the FetchProvider way).
Reach for useHarnessDataMode()/useActivationData() directly (as above)
rather than the props on a FetchProvider wherever you can — they need no
FetchProvider of your own, and useIntegrationFetch()/useActivationData()
behave identically whether or not @ekanos/harness is even installed at the
call site (a plain fetch and null, outside the harness, until a host mounts
its own provider). useHarnessDataMode() is the one exception with no SDK
equivalent — call it only from code that runs nowhere but inside the dev
harness.
FetchProvider — the legacy seam, still supported
No longer the default for a new integration.
useIntegrationFetch()from@ekanos/sdk/hooksalready returns a fetch gated correctly for whichever mode the harness toolbar is in, with nothing to write — see "No partner wiring required" above. Reach for a hand-writtenFetchProvideronly for the one case it still earns its keep: composing an authenticated fetch ONCE, at a single seam (e.g. attaching anAuthorizationheader derived fromactivationDatato every request your query functions make), instead of repeating that composition in every query function. If that is not a problem you have, skip this section.The harness patches
globalThis.fetchunderneath either path, so aFetchProvideris not an escape hatch from the harness's live-mode egress check — see "Network model" in a scaffolded project'sAGENTS.mdfor what that check does and does not mean once you leave the harness.
The provider receives fetch, mode, and activationData as props, no extra
plumbing required — this is, in miniature, the same ~twenty-line pattern
@ekanos/sdk/hooks now ships as IntegrationFetchProvider/
useIntegrationFetch():
'use client';
import { type ReactNode, createContext, use } from 'react';
import type { DataMode } from '@ekanos/harness/registry';
import type { IntegrationFetch } from '@ekanos/sdk';
const browserFetch: IntegrationFetch = (input, init) =>
globalThis.fetch(input as RequestInfo, init);
const AcmeFetchContext = createContext<IntegrationFetch>(browserFetch);
export function AcmeFetchProvider({
fetch,
children,
}: {
fetch: IntegrationFetch;
mode: DataMode;
activationData: unknown;
children: ReactNode;
}) {
return <AcmeFetchContext value={fetch}>{children}</AcmeFetchContext>;
}
/** The fetch this integration's query functions should use. */
export function useAcmeFetch(): IntegrationFetch {
return use(AcmeFetchContext);
}Composing the token onto every request, the one case this seam is still for:
export function AcmeFetchProvider({
fetch,
activationData,
children,
}: {
fetch: IntegrationFetch;
mode: DataMode;
activationData: unknown;
children: ReactNode;
}) {
const token = (activationData as { apiToken?: string } | null)?.apiToken;
const authedFetch: IntegrationFetch = (input, init) =>
fetch(input, {
...init,
headers: {
...(init?.headers as Record<string, string> | undefined),
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
return <AcmeFetchContext value={authedFetch}>{children}</AcmeFetchContext>;
}Either way — the SDK's hook or a hand-rolled one — have your query functions
take an IntegrationFetch as an argument rather than reaching for a
global:
import type { IntegrationFetch } from '@ekanos/sdk';
export async function fetchAcmeSummary(
fetch: IntegrationFetch,
accountId: string,
) {
const response = await fetch(
`https://api.acme.example/v1/summary?a=${accountId}`,
);
if (!response.ok) throw new Error(`Acme returned ${response.status}.`);
return response.json();
}
// in the hook
const fetch = useIntegrationFetch(); // or useAcmeFetch() for the legacy seam
useQuery({
queryKey: acmeKeys.summary(accountId),
queryFn: () => fetchAcmeSummary(fetch, accountId),
});That is the value of the pattern, beyond the harness: the same code path
serves ctx.fetch on the server and this hook's fetch on the client — gated
by the harness's own check in dev, and a plain, unmediated browser fetch in
production today (see "Network model" in AGENTS.md).
The provider's props are fixed by the type — { fetch: IntegrationFetch; mode:
DataMode; activationData: unknown; children: ReactNode } — because the harness
mounts it, alongside (not instead of) the SDK's own providers, whenever an
entry declares one. Omit FetchProvider entirely if your widgets fetch only
through your own host routes: those are answered from fixtures (record them
under /api/…) in EVERY toolbar mode, live included — there is no Supabase
and no ctx to execute a route with, so there is nothing "live" to run for
that traffic either way.
Triggers — running webhooks, schedules and OAuth locally
Set definition on the registry entry and the Triggers surface reads your
declared webhooks, schedules and OAuth straight off it. Omit it and the surface
explains what to add.
import { asHarnessDefinition } from '@ekanos/harness/registry';
import { integration as acmeDefinition } from '@you/acme-payments/integration';
definition: asHarnessDefinition(acmeDefinition),asHarnessDefinition() erases the storage generic so the definition can sit in
the heterogeneous registry array — the same erasure the host performs when it
registers you. Nothing rests on the generic surviving: the mock context is built
FROM the definition's own storage schemas and validates every read and write
against them at runtime.
What the surface then does:
- Webhooks get a payload editor, seeded from
examplePayload. Delivering runs your REAL handler. The payload is validated againstpayloadSchemafirst; an invalid one never reaches the handler. Signature verification is logged as skipped — it is the transport's job in every environment. - Schedules get a "Run now" button, which invokes the handler with
trigger: 'manual'. - OAuth gets the declaration readout, an egress-coverage check on both
endpoint origins, and the Local OAuth panel — in live mode, the real
authorize → callback →
onTokensloop runs through the shell against your own developer app. See the next section.
In fixtures mode every invocation runs in the browser against one
createMockContext() per visit, seeded from triggerMocks, so state
accumulates across invocations the way it would in a real account and leaving
the page resets it. In live mode the same buttons post to
/api/integrations/<slug>/trigger and the handlers run on the server-held
live context — the one the OAuth loop stored tokens in, whose ctx.fetch
reaches your vendor under the declared egress. Same result panel either way.
onActivate runs on the Activation surface, not here. Submitting the
activation form there runs your real onActivate hook (if declared) against
the same context the mode above selects — the browser mock in fixtures, the
live context in live — and shows the outcome — ran / skipped (not declared) /
threw with the message — right under the connect dialog.
Local OAuth — the loop runs here, in live mode
For an integration whose definition declares oauth:
Switch the toolbar's Data dropdown to Live API (the entry needs a
liveblock). If your widgets never call the vendor from the browser — the OAuth shape, where every vendor call is server-side behindctx.fetch— declarelive: { default: 'live' }and the harness starts there; a choice made at the toolbar still wins and is remembered.The Local OAuth panel on the Activation and Triggers surfaces opens with the part only a human can do: a checklist for your provider's developer console — create the app, add the redirect URI
http://localhost:3100/api/integrations/<slug>/oauth/callback(Copy button), enable the declared scopes, allow the code grant (with PKCE when declared), copy the client id and secret. Everything there is derived from your declaration; addoauth.setupto the harness entry for the two things it cannot know — where the console is and what the provider calls each key:oauth: { setup: { consoleUrl: 'https://admindemo.docusign.com/apps-and-keys', consoleLabel: 'DocuSign Admin → Apps and Keys', notes: ['Integration Key is the client id; add a Secret Key — shown once.'], }, },Below the checklist is a form with one field per secret name your definition declares (
oauth.credentials.clientIdSecretName,clientSecretSecretName, and every signed webhook'ssecretName). Paste the values and Save credentials. The shell writes them to.ekanos/local-secrets.jsonat your project root (gitignored, never packed byekanos publish, read on every request) and never echoes them back — a saved key shows asfilledbeside an empty field; a blank field keeps what is saved. The panel works in fixtures mode too, so you can set up before switching.Click Connect in the activation dialog. The shell's
oauth/connectroute builds the authorize URL (PKCE when declared) and sends the browser to your provider;oauth/callbackverifies the single-usestate, exchanges the code throughctx.fetch— an undeclared token-endpoint origin fails withEgressDeniedErrorexactly as on the host — and awaits youronTokenson the live context.oauth/statusthen answers connected, the SDK'sOAuthActivationFormauto-activates, andonActivateruns on that same context.Your widgets read what those handlers wrote through the ordinary
fetchIntegrationStorageroute, unchanged; an empty read runs your declared schedules once first, as the host does.
The connection is memory-only: restarting the dev server forgets it and you
Connect again. Editing a value in the secrets file rebuilds the context and
forgets it too. The API routes refuse any request whose Host is not loopback
unless EKANOS_HARNESS_EXPOSED=1 — ekanos dev --host sets that, after its
warning.
In fixtures mode none of this runs. The harness answers oauth/status
from the entry's oauth.connected — connected by default, so an OAuth widget
renders with zero traffic and you do not hand-write that fixture:
oauth: { connected: { empty: false } }, // show the Connect prompt on `empty`Credential-form integrations — an API token, no OAuth
An integration whose credential is a field in its activation form (an API
token, a site id) needs no OAuth and no entry in local-secrets.json by hand.
In live mode, submitting the form does what the host does with the
activation row: every string field of the submitted activationData becomes
ctx.secrets.get('<that exact field name>') for your live handlers, actions
and tools (non-string fields do not). There is no secret marker to declare —
the host has none either, and a definition cannot name form fields because the
form is a component.
The shell stores those fields under an activationData object in your slug's
block of .ekanos/local-secrets.json (same file, mode 0600, gitignored),
so they survive a dev-server restart; Disconnect removes them. A later
submission replaces the previous one, as the host replaces the row. A field
that collides with an admin-tier name (an OAuth client secret, a webhook key)
wins, again as on the host. The values go from your form to the local dev
server only, are never logged, and are never sent back — the route answers
with field names. Fixtures mode is unchanged: nothing is stored and nothing
leaves the browser.
triggerMocks
The mock context is derived from the definition — slug, storage schemas, egress
— and from your fixtures, which answer ctx.fetch here exactly as they answer
a widget. triggerMocks supplies the rest: whatever Ekanos-side state your
handlers need to run a happy path.
So do not re-author your vendor payloads as fetchHandlers. That was the
double-authoring HTTP fixtures exist to kill — the same data in a domain shape
for widgets and a wire shape for handlers, with nothing relating them. Reach for
fetchHandlers only when a response has to be dynamic or stateful; an explicit
handler is tried first and wins for the URLs it matches, so you keep fixtures
for everything else.
One behaviour worth knowing: a handler calling an endpoint nothing answers gets
the same named NoRecordedResponseError a widget gets. createMockContext()'s
own fallback is a plausible empty 200 — the trigger-side twin of a silent
fixture miss — and the harness replaces it.
triggerMocks: {
// Storage rows per scope, validated against your declared schemas.
storage: {
account: { config: { merchantId: 'mrc_4820193', environment: 'sandbox' } },
user: {},
},
// Secrets by tier. `account` is writable via ctx.secrets.set(); `admin`
// stands in for admin-issued credentials and rejects writes.
secrets: {
account: { acme_api_key: 'acme_test_9f2c41ab' },
},
// ONLY for a response that has to be dynamic or stateful — your `fixtures`
// already answer ctx.fetch. A string `match` matches URLs that start with
// it; a RegExp is tested against the full URL. Tried before the fixtures.
fetchHandlers: [
{
match: 'https://api.acme.example',
respond: () => Response.json({ payments: [], totalCount: 0 }),
},
],
// Optional: run on a Source connection, scoped to the values routed to the
// team (plan 241). `routing` needs `connection: { owner: 'source' }`.
connection: { owner: 'source' },
routing: { key: 'organization', values: ['360001234567'] },
},There is no Supabase and no network in here either way. Keep the values fake — see the bundle note below.
Actions — invoking declared server handlers from a widget
useIntegrationAction() (@ekanos/sdk/hooks) is the write counterpart to
useIntegrationFetch()/fetchIntegrationStorage(): a widget's Save button
posts the user's own typed values straight to a declared actions[name]
handler — deterministic, no assistant, no natural-language round trip.
Unlike a widget's read path, there is no client-side mock to short-circuit —
the hook always makes a real fetch() — so the harness answers
POST /api/integrations/<slug>/actions/<actionName> itself, in BOTH modes,
over the same local API router that already serves storage, OAuth, webhooks
and schedules (createHarnessApiHandlers). What differs between modes is
which context resolves the call, exactly as it does for Triggers above:
- Fixtures (default). The route validates the posted input against the
action's declared
inputschema exactly like production, then runshandler(ctx, input)against onecreateMockContext()— the identical mechanisminvokeWebhook/invokeSchedulealready use. An action is executed the same way a trigger is; it's just invoked from a rendered widget's own Save button instead of the Triggers page's "Run now". - Live (opt-in). The route runs the handler against the same
server-held live context per integration that OAuth and live schedules
already use — its
ctx.fetchreaches your real vendor under the definition's declaredegress, with credentials from.ekanos/local-secrets.jsonor whateveronTokens/the activation form stored. Requires the entry'sliveblock, same as every other live surface — an integration with noliveblock stays on fixtures for its actions exactly as it does for triggers.
The session confirm
An action is a write, and unlike Triggers' dedicated "Run now" button, it
fires from ordinary widget interaction — a partner debugging an
unrelated CSS issue on the same widget while the toolbar happens to be on
Live could otherwise trigger a real vendor write without meaning to touch
data at all. So the first useIntegrationAction() call in a dev-server
session while the toolbar's Data mode is Live raises a confirmation
("This will run against real <vendor> data") before it reaches the live
context; acknowledging it ("allow for this session") lets every subsequent
action call that session proceed without asking again. Reloading the page or
restarting the dev server resets it, same as the live connection itself.
This is a harness convenience for accidental clicks during iteration, not
a production security boundary. Production runs exactly one mode — there
is no fixtures/live toggle and no per-session confirmation to bypass — and
enforces the real thing through ctx.fetch's egress allowlist regardless of
anything the toolbar or this confirm ever did. Do not describe the
per-session confirm to a partner as protection that carries over once the
integration is published.
Pinned error codes
The route answers the same fixed codes the production host route does, so
a widget's error handling written against the harness behaves identically
once it ships for real: action_not_found (404, unknown action name),
invalid_input (400, with the partner's own zod issues), rate_limited
(429), and handler_error (500 — a handler threw, or its result failed the
optional output schema; opaque by default). A handler that throws
PublicActionError (@ekanos/sdk/integration) gets its own message shown
verbatim under that same handler_error code — the one escape hatch from
the generic message. useIntegrationAction()'s rejected mutation always
surfaces these as a typed IntegrationActionError (status, code,
issues?), plus two transport-only codes the hook assigns itself —
network_error / invalid_response — for a request that never reached a
valid envelope at all. The harness does not itself enforce the production
rate limiter (there is exactly one developer hitting this route locally);
it recognizes and can answer rate_limited the same way, but nothing here
exercises it under normal use.
What ends up in the browser bundle
Everything in your registry does, including triggerMocks.secrets.
The shell's injection module is a client module — it has to be, because the
registry holds live React component references — so the whole of
harness.config.ts is compiled into the browser bundle and written into the
.next build output on disk. There is no boundary that can prevent this while
the registry still reaches the routes, so the harness warns at startup rather
than pretending otherwise:
[harness] acme-widgets seed triggerMocks.secrets, and those literals are
compiled into the browser bundle and written into the .next build output …In practice this is your own mock values on your own machine, which is why it is a warning and not a refusal. Two things follow from it:
- Keep trigger mocks fake. They are seeds for a local mock context, not credentials — nothing in the harness ever authenticates.
- Do not commit or deploy the generated build directory.
.ekanos/is gitignored for you; the.nextoutput inside it is a build artifact.
Related, and deliberate: the activation surface hides credential-shaped fields in its payload readout behind a Reveal values toggle, and the fixture activation actions log a redacted copy. That readout is the one place a real API token predictably appears on screen.
Exports
| Subpath | Contents |
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| @ekanos/harness/registry | HarnessIntegration, HarnessWidget, HttpFixture, FixtureSeed, FixtureVariant, HarnessLiveMode, HarnessTriggerMocks, the HARNESS_* ids and the pure helpers. Server-safe; this is the type surface you author against. |
| @ekanos/harness/config | defineHarnessConfig() — an identity function that exists for the inference. |
| @ekanos/harness/app | RootLayout, IndexPage, harnessMetadata. |
| @ekanos/harness/routes | IntegrationLayout, WidgetsPage, SingleWidgetPage, TilePage, ActivationPage, TriggersPage. |
| @ekanos/harness/hooks | useHarnessDataMode(), useLiveActivationData() — see "Knowing the mode, and the activation-data channel" above. Prefer FetchProvider's own mode/activationData props where you can; reach for these only from code that runs nowhere but inside the dev harness. |
| @ekanos/harness/api | createHarnessApiHandlers(registry) — the SERVER entry the shell's one API route injects your registry into. Serves oauth/{connect,callback,status}, storage, trigger and local-setup per integration in live mode. Never import it from a 'use client' module. |
| @ekanos/harness/styles.css | The harness's Tailwind layer: the Tailwind entry, the @ekanos/ui token preset, the Font Awesome repairs, and the @source globs covering everything the chrome renders. |
| @ekanos/harness/mocks/team-account-workspace | The stand-in for the host's useTeamAccountWorkspace(). The generated next.config.mjs aliases @kit/team-accounts/hooks/use-team-account-workspace onto it, so unmodified widget source runs unchanged. |
The shell contract
package.json declares ekanos.shellContract. @ekanos/cli reads it before
scaffolding and refuses if its templates generate a different one, naming which
side to upgrade.
Bump it when the SHELL this package requires changes — an export renamed or
moved between /app and /routes, the props of RootLayout or IndexPage,
the server/client split, the alias specifier, the stylesheet's ownership of the
Tailwind import, or the set of files the shell must contain. A release that only
fixes a bug inside the package leaves it alone. History: 1 the original
ten-file shell; 2 (0.2.0) adds app/api/integrations/[slug]/[...path]/route.ts
injecting the registry into @ekanos/harness/api.
A shell change also needs a @ekanos/cli release. The templates live there;
only the code they import lives here. ekanos dev rewrites a generated file
only when its bytes change, so bumping the harness alone regenerates nothing —
the old CLI's templates are exactly what the old CLI intends. The contract
integer is what turns that silent mismatch into a refusal.
What it does NOT cover is your harness.config.ts. The contract is checked
before the CLI writes the generated shell, so it guards .ekanos/harness/** —
our files. Yours is bootstrapped once and never touched again, deliberately,
because your fixtures live in it. So if we rename something in the registry
types you write against, it reaches you as a TypeScript error in a file no tool
will migrate for you. That is the right trade — we are not editing your file —
but it means the release notes are the migration path, and you should read
them on a minor bump rather than only on a major. The HarnessWidget.fixtures
→ seeds rename — made when HarnessIntegration.fixtures took the name — is
exactly this shape.
Building it
pnpm --filter @ekanos/harness build@ekanos/ui must be built first — styles.css imports its token preset.
The build compiles per file rather than bundling (bundle: false in
tsup.config.ts), and that is load-bearing in three separate ways: the
server/client boundary runs through the /app entry rather than around it,
next/font/google requires its call to survive as a const (esbuild's bundler
rewrites top-level const to var, which Next rejects outright), and there is
nothing to inline in the first place. Each of those is a bug we have already
shipped once; the build config in the source tree carries the long version.
pnpm --filter @ekanos/harness pack:test re-checks all of that on the actual
published tarball, in a clean room outside the workspace.
Licence
MIT. Font Awesome is the consuming app's responsibility: this package ships no icon-font data, and the generated shell loads Font Awesome Free, because Ekanos's own Pro webfonts are commercially licensed and cannot be redistributed.
