npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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. :param matches exactly one segment, * matches the rest (including nothing). So /v1/payouts does not match /v1/payouts/42, but /v1/payouts/:id and /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 makes GET https://api.acme.com/v1/payouts answer 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:

  1. 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) or refused — so the console is the fastest place to see what a widget actually asked for versus what you recorded. A same-origin /api/… request logs fixture or refused in 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 log network, and only in live mode.

  2. The fetch call rejects with NoRecordedResponseError, 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.
  3. 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 sets retry: false, so immediately rather than after three backoffs). A widget that starts a promise in render and reads it with use() 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 data in the shape your hook returns — domain-shaped, post-parse. A triggerMocks.fetchHandlers entry returns a Response, 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. queryKey was the only way to address one, so a partner fetching with SWR, in a server component with plain fetch, or from a useEffect had 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/hooks already 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-written FetchProvider only for the one case it still earns its keep: composing an authenticated fetch ONCE, at a single seam (e.g. attaching an Authorization header derived from activationData to 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.fetch underneath either path, so a FetchProvider is not an escape hatch from the harness's live-mode egress check — see "Network model" in a scaffolded project's AGENTS.md for 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 against payloadSchema first; 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 → onTokens loop 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:

  1. Switch the toolbar's Data dropdown to Live API (the entry needs a live block). If your widgets never call the vendor from the browser — the OAuth shape, where every vendor call is server-side behind ctx.fetch — declare live: { default: 'live' } and the harness starts there; a choice made at the toolbar still wins and is remembered.

  2. 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; add oauth.setup to 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's secretName). Paste the values and Save credentials. The shell writes them to .ekanos/local-secrets.json at your project root (gitignored, never packed by ekanos publish, read on every request) and never echoes them back — a saved key shows as filled beside an empty field; a blank field keeps what is saved. The panel works in fixtures mode too, so you can set up before switching.

  3. Click Connect in the activation dialog. The shell's oauth/connect route builds the authorize URL (PKCE when declared) and sends the browser to your provider; oauth/callback verifies the single-use state, exchanges the code through ctx.fetch — an undeclared token-endpoint origin fails with EgressDeniedError exactly as on the host — and awaits your onTokens on the live context. oauth/status then answers connected, the SDK's OAuthActivationForm auto-activates, and onActivate runs on that same context.

  4. Your widgets read what those handlers wrote through the ordinary fetchIntegrationStorage route, 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 input schema exactly like production, then runs handler(ctx, input) against one createMockContext() — the identical mechanism invokeWebhook/invokeSchedule already 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.fetch reaches your real vendor under the definition's declared egress, with credentials from .ekanos/local-secrets.json or whatever onTokens/the activation form stored. Requires the entry's live block, same as every other live surface — an integration with no live block 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 .next output 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.