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/sdk

v0.18.0

Published

The official SDK for building Ekanos integrations.

Readme

@ekanos/sdk

The official SDK for building Ekanos integrations — the packages that add widgets, assistant tools, and event handlers to an Ekanos dashboard.

Docs: sdk.ekanos.dev is the public documentation for the SDK, CLI and harness, and the first place to look for current answers. Start here. This README is the complete authoring reference: everything you can declare, with a worked example for each surface. If something is missing from both, [email protected] is where to say so.

The other packages, so you know when to leave this page: @ekanos/cli is the toolchain (init, validate, dev, test) and owns ekanos.json; @ekanos/harness is the local dev harness that renders your surfaces in real Ekanos chrome; @ekanos/ui is the component library; and @ekanos/integration-schema is the validation contract underneath this one. Each has its own README.

Install

npm install @ekanos/sdk

@ekanos/integration-schema and @ekanos/ui come along as dependencies. You provide six peers, and all six are declared as required — none is marked optional, so your package manager expects every one of them even if your integration only ships tools:

| Peer | Version | What actually uses it | |---|---|---| | react | ^19.2.8 | everything client-side | | zod | ^3.25.76 | storage schemas, webhook payload schemas, the definition parse | | @tanstack/react-query | ^5.101.4 | widget data hooks, and the SDK's own useActivateIntegration / useOAuthConnectionStatus | | react-hook-form | ^7.68.0 | activation forms | | @hookform/resolvers | ^5.2.2 | zod validation in activation forms | | next | ^16.0.0 | the host's routing primitives the components reach for |

The right-hand column is why each is there, not permission to skip it: leave one out and you get peer warnings on install regardless of what you render.

Why peers and not dependencies: every one of these creates React context, and two copies of a context-creating module is a bug that does not announce itself. A provider renders against instance A while a consumer three files away reads instance B, finds nothing, and silently falls back to a default. As peers, your package manager gives everyone one copy; as dependencies, npm is free to give you two.

This is not hypothetical, and it does not require two installs to happen — bundling reaches it just as easily. @ekanos/ui shipped a version where five built entries each inlined their own copy of the icon module, and so each got its own createContext(). A consumer's IconStyleOverrideProvider set one instance; the chevron inside the select entry read another, found no provider, and fell back to a Font Awesome Pro weight that Free defines no glyph for. Three of five icons rendered as □. The monorepo could not reproduce it at all — it resolves from source, where one module means one context — so it was only visible from an installed tarball. If you bundle @ekanos/* into your own output, keep these singletons.

What is published

Everything you need is on npm:

| Package | Role | |---|---| | @ekanos/sdk, @ekanos/ui, @ekanos/integration-schema | the authoring API, the widget UI slice, the schema | | @ekanos/cli | ekanos init, dev, test, validate, publish | | @ekanos/harness | the local dev harness ekanos dev runs |

Start a project with:

npx @ekanos/cli init

ekanos init scaffolds a project that already depends on the matching versions, and ekanos dev renders every surface of your integration in real Ekanos chrome — the dashboard grid, the marketplace tile, the activation form, the Triggers panel — from fixtures, or against your real vendor API in live mode. The @ekanos/harness and @ekanos/cli READMEs document both.

You can also use @ekanos/sdk on its own:

npm install @ekanos/sdk zod
  • defineIntegration() validates at import time and throws with a remediation message, so your definition is its own feedback loop.
  • The whole Testing section needs nothing but vitest. createMockContext() gives you the same context production builds, and invokeWebhook() / invokeSchedule() / invokeActivate() run your handlers with the same payload validation and egress allowlist the real transports will.

ekanos.json is the project file the CLI reads; its schema and the generated shell's file list are documented in the @ekanos/cli README.

The shape of an integration

An integration is one call to defineIntegration(). It validates at import time and hands back a deep-frozen definition; the host re-parses the same object against the same schema when it registers you, so a definition that loads locally is one the platform accepts. There is no second, looser contract.

// src/integration.ts
import { defineIntegration } from '@ekanos/sdk/integration';

export const integration = defineIntegration<MyStorage>({
  // Identity — all four required.
  slug: 'acme-payments',        // kebab-case; the route segment and MCP namespace
  name: 'Acme Payments',
  description: 'Payment volume, recent activity, and refunds.',
  version: '1.0.0',             // semver; promotion diffs definitions by it

  // Marketing copy for the detail page.
  capabilities: [ /* … */ ],
  permissions: [ /* … */ ],

  // What renders.
  components: { widgets: [], marketplaceTile, activationForm },

  // What runs.
  tools: [ /* … */ ],
  webhooks: [ /* … */ ],
  schedules: [ /* … */ ],
  oauth: { /* … */ },

  // What it may touch.
  storage: { account: {}, user: {} },
  egress: ['https://api.acme.example'],

  // What you suggest and a reviewer confirms.
  proposes: { /* … */ },
});

Only slug, name, description and version are required. Every other key is optional, and an integration that declares nothing but widgets is a perfectly good integration.

The export must be named integration. Two things import it by that name: the host's build, when your package is added to the platform's list of partner integrations, and ekanos.json — the small file @ekanos/cli keeps at your project root, holding your slug and the entry module this export lives in. The @ekanos/cli README defines that file; this one assumes it.

The schema is strict everywhere. An unrecognized key is a hard error, not a silently ignored one. That is deliberate: it is what makes host-assigned fields structurally un-settable rather than merely absent from the type. You cannot declare a productId, a kind, a trust tier, a credentialModel, or per-tool effect/sensitivity — the closest you get is proposes. Widget fields the platform populates at runtime (productId, widgetConfigId, workspaceId, collapsed, isPinned, health, integrationMetadata) are rejected the same way.

The capability context (ctx)

ctx is the entire platform surface a server-side handler gets — tools, webhook handlers, schedule handlers and oauth.onTokens all receive the same object. There is no raw database client, no Supabase, and no request object.

| Field | What it is | |---|---| | ctx.storage.account | account_product_data for this {account, product} — schema-validated | | ctx.storage.user | user_product_data for this {user, account, product}. Throws for machine actors | | ctx.secrets | get(name), names(), set(name, value) over the resolved credential set | | ctx.fetch | fetch, refusing anything outside egress before it opens a socket | | ctx.logger | debug/info/warn/error, each (context: object, message: string) — pino-style, pre-bound with {integration, accountId, actor} | | ctx.actor | { kind: 'user', userId, role } or { kind: 'machine', tokenId, createdBy }. role is { name, hierarchyLevel } (1 owner, 2 admin, 3 member; null when unresolved). Gate a write action with actorIsAtLeast(ctx.actor, ADMIN_HIERARCHY_LEVEL) from @ekanos/sdk/integration and throw PublicActionError otherwise — the host runs every action with the account's credential |

Plus read-only identity facts, all already authorized: ctx.accountId, ctx.accountSlug, ctx.userId (null for machine actors), ctx.sourceId, ctx.integration ({ slug, productId }) and ctx.timezone.

Everything on ctx is scoped before your code runs. There is no id parameter anywhere for you to get wrong, and none for you to lie in.

The logger takes the context object first:

ctx.logger.info({ paymentId, from, to }, 'Recorded a payment status change.');

Declarable surfaces

storage

Per-account (and per-user) state, keyed by strings you declare up front. Every key declares a zod schema. A key with no declaration cannot be read, written, or deleted — that is a type error where the schema map is statically known, and always a runtime StorageValidationError.

import type { StorageSchemas } from '@ekanos/sdk';
import { z } from 'zod';

const LocationSchema = z.object({
  label: z.string().min(1),
  latitude: z.number(),
  longitude: z.number(),
});

export const myStorage = {
  account: {
    // Bare schema — server-only. This is the default and the common case.
    'cache/forecast': z.object({ fetchedAt: z.string(), body: z.unknown() }),

    // Descriptor form — the only way to widen exposure.
    'settings/location': { schema: LocationSchema, clientReadable: true },
  },
  user: {
    // Reachable from server handlers only — see "Account scope only" below.
    'prefs/units': z.enum(['celsius', 'fahrenheit']),
  },
} satisfies StorageSchemas;

export type MyStorage = typeof myStorage;

Pass the type as the generic — defineIntegration<MyStorage>({ … }) — and ctx.storage.account.get('settings/location') is typed to that schema's output in every handler.

Reading and writing:

const saved = await ctx.storage.account.get('settings/location');
// → { data, externalId, expiresAt, updatedAt } | null

await ctx.storage.account.set('cache/forecast', body, {
  expiresAt: new Date(Date.now() + 600_000).toISOString(),
});

await ctx.storage.account.delete('cache/forecast');

All three are async. get returns null for a missing key and for an entry past its expiresAt. A read whose stored data no longer matches the current schema throws StorageValidationError rather than handing you a shape you did not expect — evolve a schema with a versioned key (settings/location.v2) or a z.union, never by loosening it in place.

clientReadable is what makes a key browser-readable. It defaults to false. The generic storage route (GET /api/integrations/[slug]/storage) serves only keys that opted in — so a widget can read back its own settings without your writing a route, while cursors, cached upstream payloads and internal bookkeeping stay server-side. The descriptor is .strict(), so clientReadible: true is an error rather than a key whose author believes it is exposed.

From a widget:

import { fetchIntegrationStorage } from '@ekanos/sdk/hooks';

const values = await fetchIntegrationStorage('acme-payments', {
  accountId,
  keys: ['settings/location'],
});

const location = values['settings/location'];   // unknown — narrow it yourself

It requests GET /api/integrations/<slug>/storage?accountId=…&keys=a,b and returns a { key: value } object.

Four things the signature does not tell you:

  • A key that did not opt in is simply absent, and so is a key that has never been written. The response does not distinguish them, and neither can you.
  • It throws for a failed request, never for an absent key. A 403 covers "not signed in", "not a member", "not activated" and "no such integration" — deliberately indistinguishable, so do not branch on it.
  • At most 20 keys per request. Asking for more throws before it fetches. Split the read.
  • Account scope only, and deliberately so. ctx.storage.user is not exposed on this route, so a per-user preference is server-readable only — a widget cannot fetch one, and you need your own route if you need it in the browser. The reasoning is worth knowing because it tells you what to expect: clientReadable works as an opt-in because an account-scope value is something the integration's author decided to expose, and that reasoning does not transfer to personal data, where the person it belongs to made no such decision. Adding &scope=user later is additive and would default to today's behaviour, so the door is open — it is a considered "not yet", not an oversight.

Key names are [a-z0-9_-]+ with at most one / segment: config, settings/location, cache/recent-payments. Two slashes is a validation error.

Limits. The host caps one stored value at 64 KB serialized, one secret value at 64 KB, the number of distinct secret names an integration writes at 32, and a key segment at 100 characters. The same numbers are exported as PARTNER_STORAGE_LIMITS from @ekanos/integration-schema and enforced by createMockContext(), so a test that writes an oversized value fails locally the way the host would. A snapshot of vendor records reaches 64 KB around 120–150 modest rows: trim to a byte budget and flag it rather than finding out on the host.

egress

An origin-only https allowlist. ctx.fetch refuses anything not covered before it opens a socket — redirect targets included.

egress: [
  'https://api.acme.example',      // exact origin
  'https://*.acme-cdn.example',    // single leading-label wildcard
  'https://api.acme.example:8443', // explicit port
],

Scheme, host and optional port. No path, no query, no fragment, no credentials, no http:. Wildcards are supported only as one leading *.. Anything else is rejected by defineIntegration() with the offending entry quoted.

A denied call throws EgressDeniedError synchronously. Widening this list is a security-posture change a reviewer looks at during admission — the human review your integration passes before it is enabled for real accounts, where someone reads this declaration against what your handlers actually do. Declare the origins you call and no more.

Secrets

There is no secrets block in the definition. This surprises people, so: secrets are not declared, they are named, and read back by name through ctx.secrets. There are exactly three places a name comes from, and all three are just strings you choose:

| Where the name comes from | The name is | |---|---| | Your activation form | the field name you posted, verbatim | | webhooks[].signature.secretName | whatever string you put there | | oauth.credentials.clientIdSecretName / clientSecretSecretName | whatever string you put there |

The activation form is the one every integration uses and the only one that looks like magic, so precisely:

Every field of activationData whose value is a string becomes readable as ctx.secrets.get('<that exact field name>'). There is no "credential-shaped" detection — the platform does not inspect your field names, look for a marker, or transform anything. No prefixing, no snake_casing. Non-string values (numbers, booleans, nested objects) do not become secrets.

That means a form field named apiKey is read back as ctx.secrets.get('apiKey'), not acme_api_key. It also means renaming a form field renames the secret and breaks every handler reading it — which is the argument for the shared constant below.

The host then resolves the name across its credential tiers (account → source → global) plus your activation's values, and hands your handler the resolved set. Where two tiers hold the same name, a value the user entered at activation wins over an admin-issued one; a value your own code wrote with secrets.set() does not, so a partner cannot shadow a credential an admin issues later.

const apiKey = await ctx.secrets.get('acme_api_key');   // string | null
const available = await ctx.secrets.names();            // names only, no values
await ctx.secrets.set('acme_access_token', token);      // account tier only

set() is tier-bound. It writes only to the account-tier store for this {account, product}. Source- and global-tier credentials are admin-issued and structurally unreachable from partner code — a set() against a name that resolves only from those tiers throws SecretAccessError. This is what makes OAuth refresh safe to hand to an integration.

Because names are strings on both sides, keep them in one module and import them, rather than retyping the literal in the declaration and again in the handler:

// src/config.ts — the single source for every name that crosses a boundary
export const ACME_API_KEY = 'acme_api_key';
export const ACME_SIGNATURE_HEADER = 'x-acme-signature';

Used on both sides, so the form and the handler cannot drift:

// the activation form — the field NAME is the secret name
const ActivationSchema = z.object({ [ACME_API_KEY]: z.string().min(1) });
<FormField name={ACME_API_KEY} … />
// any server handler — same constant, same string
const apiKey = await ctx.secrets.get(ACME_API_KEY);

tools

Tools are what the Ekanos assistant, workflows and managed agents can call. Each declares a name, a description the model reads, an optional JSON-Schema parameter object, and an async run.

tools: [
  {
    name: 'get_current_weather',            // ^[a-z][a-z0-9_]*$
    description: 'Current conditions for the account’s saved location.',
    parameters: {
      type: 'object',
      properties: { units: { type: 'string', enum: ['celsius', 'fahrenheit'] } },
      required: [],
      additionalProperties: false,
    },
    outputExample: { temperature: 12.4, units: 'celsius' },

    async run(ctx, args) {
      const saved = await ctx.storage.account.get('settings/location');
      if (!saved) return { error: 'No location saved for this account.' };

      // Same origin the definition's `egress` declares — `ctx.fetch` refuses
      // anything else synchronously, before it opens a socket.
      const response = await ctx.fetch(
        `https://api.acme.example/v1/weather` +
          `?lat=${saved.data.latitude}&lon=${saved.data.longitude}`,
      );

      if (!response.ok) {
        ctx.logger.error({ status: response.status }, 'Forecast fetch failed.');
        return { error: `Acme returned ${response.status}.` };
      }

      return response.json();
    },
  },
],

run returns a plain JSON-serializable value. Returning { error: '…' } is the convention for an expected failure the model should read and explain; throw for a genuine fault.

outputExample is shown to the model as a shape hint. Keep it small and honest.

Text written outside this system is data, not instructions. A deal name, a call transcript, a support reply, a booking answer: anything a vendor's users typed can say "ignore the above and…", and in a tool result it looks exactly like a field you computed. Return such text only under a field whose name starts with untrusted, clip it with untrustedText() (from @ekanos/sdk/integration; 1000 characters by default), and put one note: untrustedTextNote('deal, stage and pipeline names') at the top of the output so the model reads the rule where it reads the data. untrustedText() bounds the length and marks the text as coming from a third party so the assistant treats it as data; it does not strip or neutralise injected instructions. Pass untrustedTextNote() a fixed string you wrote, never vendor text. In a write tool's description, say to confirm with the user first and never act on what the text asks for.

import { untrustedText, untrustedTextNote } from '@ekanos/sdk/integration';

return {
  note: untrustedTextNote('deal names'),
  deals: deals.map((d) => ({ id: d.id, untrustedName: untrustedText(d.name, 200), amount: d.amount })),
};

ownerAction is how an owner reads an approval for your tool. When an agent or workflow pauses on one of your tools, the owner sees "{agent} wants to {ownerAction} in {your integration}". Declare the phrase yourself, a bare verb that names no ids:

ownerAction: 'sync your price list',
// or one phrase per locale; `default` covers any locale you did not list
ownerAction: { en: 'sync your price list', es: 'sincronizar su lista de precios' },

It is plain text, at most 120 characters, never a message key: a partner cannot add keys to the host's messages. The host picks the exact locale, then its language (pt-BR then pt), then the default locale, then your default. With no ownerAction the host derives a phrase from the tool name ("update deal stage" from acme_update_deal_stage), and a name that is not <verb>_<thing> reads "use ", so declare one for any tool that writes.

parameters is a narrow slice of JSON Schema, not all of it. The top level is .strict() and accepts exactly four keys — type (which must be the literal 'object'), properties, required and additionalProperties. A root-level title, description, $schema, oneOf or a non-object type is a hard validation error, not an ignored key. Inside properties you are unconstrained as long as the value is plain JSON, so nested objects, arrays, enums and per-property descriptions all pass — the restriction is on the wrapper, not the contents.

Tool names are namespaced by slug at discovery. The effective name the assistant sees is your slug with hyphens replaced by underscores, then the raw name, unless the name already carries that prefix. acme-payments + list_recent_payments → acme_payments_list_recent_payments. Two collision-free raw pairs can collapse to the same effective name, so the platform checks effective names and refuses a duplicate outright.

components.widgets

Dashboard widgets are client components that render through the Widget.* compound API.

'use client';

import { use } from 'react';

import type { IntegrationComponentProps } from '@ekanos/sdk';
import { Widget, WidgetContext } from '@ekanos/sdk/components';

export function CurrentWeatherWidget({ accountId }: IntegrationComponentProps) {
  const ctx = use(WidgetContext);
  // Skip fetching while the integration is inactive.
  const query = useCurrentWeather(accountId, ctx?.state.state === 'active');

  return (
    <Widget.DataState
      loading={query.isLoading}
      error={query.isError}
      // Optional. They add the header menu's Refresh item, a Try again
      // button on the error view, and an "Updated …" footer.
      onRefresh={() => void query.refetch()}
      refreshing={query.isFetching}
      updatedAt={query.dataUpdatedAt}
    >
      <Widget.Card>
        <Widget.Header title="Current conditions" />
        <Widget.Loading />
        <Widget.Error />
        <Widget.Active>{/* … */}</Widget.Active>
      </Widget.Card>
    </Widget.DataState>
  );
}

Widget.Active is intentionally flush. Its CardContent renders p-0 with no padding, so a chart, table, or other full-bleed content can run edge-to-edge — existing widgets depend on this. Widget.Header stays inset (px-5), so ordinary content (text, rows, a KPI readout) rendered directly inside Widget.Active sits flush against the card edges while the header above it does not. For that case, wrap your content in Widget.Body — the standard padded body (px-5 pt-4 pb-5, matching the header's inset) — rather than hand-rolling a px-* wrapper:

<Widget.Active>
  <Widget.Body>{/* rows, text, a KPI readout */}</Widget.Body>
</Widget.Active>

Declared like this:

components: {
  widgets: [
    {
      // Widget ids are GLOBAL — the dashboard resolves widget_config rows by
      // them — so prefix every one with your slug.
      id: 'acme-payments-summary',       // kebab-case
      name: 'Acme Payments',
      component: AcmePaymentsSummaryWidget,
      widgetState: 'active',             // 'active' | 'inactive' | 'disabled'
      isCollapsible: true,
      isPinnable: false,
      aiFooterEnabled: true,             // renders the "Ask about this" footer
      layouts: {
        lg: { x: 0, y: 0, w: 4, h: 8 },
        md: { x: 0, y: 0, w: 4, h: 8 },
        sm: { x: 0, y: 0, w: 4, h: 8 },
      },
    },
  ],
},

widgetState is the only required field beyond id, name and component. layouts seeds the default dashboard placement per breakpoint; gridSize and gridPosition are the older equivalents and still accepted.

Widget settings live in ctx.storage, which is server-side, so a widget reads them back over HTTP with fetchIntegrationStorage() from @ekanos/sdk/hooks — subject to clientReadable, above.

To render a widget anywhere other than the Ekanos dashboard — your own app, a story, a component test — wrap it in WidgetPreviewProvider, which fills the same WidgetContext the host fills, with local state and no persistence:

import { WidgetPreviewProvider } from '@ekanos/sdk/components';

<WidgetPreviewProvider widgetId="acme-payments-summary" title="Payments">
  <AcmePaymentsSummaryWidget accountId={accountId} productSlug="acme-payments" />
</WidgetPreviewProvider>;

components.marketplaceTile

The card a source admin sees in the marketplace before connecting. Keep it plain: no context, no queries, no state, so it renders anywhere from props alone.

'use client';

import type { MarketplaceTileProps } from '@ekanos/sdk';
import { Badge } from '@ekanos/ui/badge';
import { Button } from '@ekanos/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@ekanos/ui/card';
import { Icon } from '@ekanos/ui/icon';

export function AcmePaymentsMarketplaceTile({
  onActivate,
  isActivated = false,
  href,
}: MarketplaceTileProps) {
  return (
    <Card className="shadow-ring flex h-full flex-col border-0">
      <CardHeader className="gap-3">
        <div className="flex items-start justify-between gap-3">
          <div className="bg-muted flex h-12 w-12 items-center justify-center rounded-lg">
            <Icon name="fa-credit-card" className="h-6 w-6" aria-hidden />
          </div>
          {isActivated && <Badge variant="secondary">Connected</Badge>}
        </div>
        <CardTitle className="text-lg">Acme Payments</CardTitle>
        <CardDescription>Volume, recent activity, and refunds.</CardDescription>
      </CardHeader>

      <CardContent className="mt-auto flex items-center justify-between gap-3 pb-6">
        <span className="text-muted-foreground text-sm">Payments</span>
        {isActivated ? (
          <Button variant="outline" size="sm" nativeButton={false} render={<a href={href ?? '#'} />}>
            Manage
          </Button>
        ) : (
          <Button size="sm" onClick={onActivate}>Connect</Button>
        )}
      </CardContent>
    </Card>
  );
}

MarketplaceTileProps is IntegrationComponentProps plus onActivate?, isActivated? and href?. IntegrationComponentProps itself is { accountId, sourceId?, integrationConfig? } plus exactly one of productId or productSlug — a discriminated union, so passing both is a type error.

A tile is optional. Omit it and the marketplace renders a generic card from your name and description, plus whatever logo the platform holds for your product — the logo is assigned host-side, not declared here.

components.activationForm

What a user fills in to connect — only that. The host renders a small frame around your form: your product's logo, name and one-line summary, then your form. Nothing sits between the two. The host also owns the success moment: it closes and toasts, naming the workspace your widgets landed in. BaseActivationForm renders the action — Connect and Cancel right-aligned under your fields, or, for a form with no fields, a single full-width button — plus "Connecting…" while busy and the inline error. You supply the react-hook-form instance and the fields. Nothing else belongs in a form: no title, feature list, permission badge, connected banner or success screen. The connect step is kept to the one decision the person already made by clicking your product.

'use client';

import { useState } from 'react';

import type { ActivationFormProps } from '@ekanos/sdk';
import { BaseActivationForm } from '@ekanos/sdk/components';
import { useActivateIntegration } from '@ekanos/sdk/hooks';
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@ekanos/ui/form';
import { Input } from '@ekanos/ui/input';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';

import { ACME_API_KEY } from './config';   // 'acme_api_key'

// Field names ARE secret names — see the note below the example.
const ActivationSchema = z.object({
  [ACME_API_KEY]: z.string().min(1, 'Required'),
  merchantId: z.string().min(1, 'Required'),
});

export function AcmePaymentsActivationForm({
  accountId,
  sourceId,
  onSuccess,
  onCancel,
  isLoading: externalLoading = false,
  inline = false,          // pass this through — see below
}: ActivationFormProps) {
  const [error, setError] = useState<string | null>(null);

  const form = useForm({ resolver: zodResolver(ActivationSchema),
                         defaultValues: { [ACME_API_KEY]: '', merchantId: '' } });

  const activate = useActivateIntegration();
  const isLoading = externalLoading || activate.isPending;

  const handleSubmit = async (data: Record<string, unknown>) => {
    setError(null);
    try {
      await activate.mutateAsync({
        accountId, sourceId,
        integrationSlug: 'acme-payments',
        activationData: data,
      });
      // Report once, straight to the host. It closes and toasts.
      onSuccess?.({ success: true, externalId: data.merchantId as string });
    } catch (cause) {
      setError(cause instanceof Error ? cause.message : 'Could not connect.');
    }
  };

  return (
    <BaseActivationForm
      isOpen inline={inline}
      onClose={() => onCancel?.()}
      title="Connect Acme Payments"
      description="Bring volume, activity and refunds into your dashboard."
      form={form}
      onSubmit={handleSubmit}
      isLoading={isLoading}
      error={error}
    >
      <FormField control={form.control} name={ACME_API_KEY} render={({ field }) => (
        <FormItem>
          <FormLabel>API key</FormLabel>
          <FormControl><Input type="password" {...field} /></FormControl>
          <FormMessage />
        </FormItem>
      )} />
    </BaseActivationForm>
  );
}

Three things worth knowing:

  • Pass inline through. Without it the form mounts its own dialog, so any page that embeds it ends up with nested dialogs.

  • Every string field you submit is readable as a secret, under the exact name you gave it. There is no "credential-shaped" detection: the platform does not inspect your field names or look for a marker. activateIntegration takes whatever activationData object you post, and every field whose value is a string becomes readable in your server handlers as ctx.secrets.get('<that same field name>') and appears in ctx.secrets.names(). Non-string values (numbers, booleans, nested objects) do not.

    So the form field name is the secret name — no prefixing, no snake_casing, no transformation of any kind. Renaming a form field renames the secret and breaks every handler reading it, so put the name in one constant and use it on both sides:

    // src/config.ts — imported by the form AND by every handler
    export const ACME_API_KEY = 'acme_api_key';

    Values you want persisted as settings (not secrets) go in configData on the ActivationResult you hand onSuccess, keyed by the settings/* storage keys you declared. Never put raw field values there: the host rejects any key you did not declare, and the user sees a warning that "some settings could not be saved".

  • useActivateIntegration needs the host's injected actions. It reads them from IntegrationActivationProvider, which the host mounts. Outside the host — in the dev harness, or your own app — mount that provider yourself, or the hook has nothing to call.

OAuthActivationForm is the one-button variant for OAuth integrations: it renders Continue with Acme with a line explaining the user will sign in on Acme's site and come straight back, then activates on return. Its config needs only slug and displayName (plus connectPath, errorParam or autoActivateOnConnect: false when you genuinely need them). useOAuthConnectionStatus() reports whether the account is connected.

Both primitives accept a footnote — one quiet line under the action row for what happens next — and nothing else decorative.

Reconnecting. The host mounts your form a second time, with reconnect set, when a connected integration needs its credentials renewed — after a failed health check, or when the owner chooses Reconnect. OAuthActivationForm handles it for you: the button reads Reconnect with Acme and always goes through sign-in again, even with a token stored. A credential form needs nothing special: it collects new values and activates again, which replaces the old ones and resets the integration's health to unknown until the next check. A bespoke OAuth form should treat reconnect the same way — send the person through sign-in rather than skipping it because a token exists.

Behavior change for existing OAuth forms: OAuthActivationForm's children render only after the user has signed in, and only for a form with autoActivateOnConnect: false is there a moment to see them. They are for post-sign-in fields, not for feature lists or permission copy — the connect step carries neither. The primitive does not read those fields: a form that needs values after sign-in owns its own useForm on BaseActivationForm and saves them before activating (the GitHub form is the reference). If your form passed such a child, delete it; the same copy belongs in permissions / capabilities.

A form is optional. Omit it and the host renders a generic one — a plain field per value it needs — rather than nothing.

capabilities and permissions

Both are pure data describing what your integration does and touches. They are not shown on the connect dialog — that step is only your name, summary and form. They appear in the platform's product catalog, where a source's administrators review an integration before offering it to teams. Write detail as a sentence you would be happy for a customer's security reviewer to read, because that is who reads it. For an OAuth integration, make them match the scopes you request: the provider's consent screen is what the person connecting actually sees.

The shape each entry takes:

| Section | Renders | |---|---| | What it does | capability.label, then — + capability.description when present. capability.icon is not rendered here | | What it can access | permission.label, a badge reading Read or Write from permission.type, then permission.detail on the line below |

An empty array simply declares nothing. type drives only the Read/Write label — it does not reorder or group anything.

capabilities: [
  {
    label: 'Volume at a glance',
    description: 'Gross volume, trend, and success/failure counts on one card.',
    // icon?: an optional component reference — accepted by the schema, but
    //        NOT rendered on the activation surface
  },
],

permissions: [
  {
    label: 'Read payments',
    detail: 'Lists payments and aggregate volume for the connected merchant.',
    type: 'read',                    // 'read' | 'write'
  },
  {
    label: 'Issue refunds',
    detail: 'Submits refunds against payments for the connected merchant.',
    type: 'write',
  },
],

permissions is a disclosure to the person connecting the integration, not an enforcement mechanism. What actually constrains you is egress, the storage schemas, and the secret tiers.

webhooks

An inbound delivery from your provider. The declaration is the whole contract: the transport validates the payload against payloadSchema, verifies the signature, and calls your handler with the same ctx every other handler gets.

import type { PartnerWebhookDeclaration } from '@ekanos/sdk/integration';

export const paymentUpdatedWebhook: PartnerWebhookDeclaration<MyStorage> = {
  id: 'payment-updated',              // kebab-case; the ingress addresses it by this
                                      // (NOT the vendor's event name — see below)
  description:
    'Acme posts one event per payment status change. Keeps the dashboard cache honest.',

  // Validates every delivery BEFORE the handler runs.
  payloadSchema: PaymentUpdatedPayloadSchema,

  // How the TRANSPORT verifies. `'none'` states explicitly that the source
  // is unsigned. Your handler never checks a signature. Add `scheme` when the
  // provider is not a plain HMAC-SHA256 digest of the body — see below.
  signature: { header: 'x-acme-signature', secretName: 'acme_webhook_secret' },

  // Seeds the harness's payload editor and documents the shape beside the
  // schema. Plain JSON only.
  examplePayload: {
    eventType: 'payment.updated',
    payment: {
      id: 'pay_3RkQ9fLm2xTvB1',
      customerName: 'Northwind Logistics',
      amountCents: 128450,
      currency: 'USD',
      status: 'succeeded',
    },
    previousStatus: 'pending',
  },

  async handler(ctx, event) {
    // event: { id, receivedAt, headers, payload }
    // `payload` is already parsed and validated — narrow it to your type.
    const { payment, previousStatus } = event.payload as PaymentUpdatedPayload;

    if (payment.status === previousStatus) {
      // A no-op redelivery. Acknowledged (2xx — do not retry), recorded as
      // irrelevant.
      return { status: 'ignored', detail: `Still "${payment.status}".` };
    }

    await ctx.storage.account.set('events/last-payment-update', {
      payment, previousStatus, receivedAt: event.receivedAt,
    });
    await ctx.storage.account.delete('cache/recent-payments');

    return { status: 'processed', detail: `${previousStatus} → ${payment.status}.` };
  },
};

The three-way result contract matters:

| Outcome | How to signal it | What the transport does | |---|---|---| | Handled it | return { status: 'processed' } | 2xx | | Valid but irrelevant | return { status: 'ignored' } | 2xx — the sender must not retry | | Could not handle a valid event | throw | retryable failure |

detail is optional on both statuses and shows up in the harness and the host's delivery log.

Webhook ids are kebab-case, so you cannot name one after the vendor's event. GitHub sends pull_request, Stripe sends payment_intent.succeeded, Slack sends app_mention — none of those are valid ids. Plan on keeping an explicit map from vendor event name to your webhook id, and put the vendor's name in description so the two are readable side by side. The validation error is clear about the rule, but it fires after you have already named things.

A payload that fails payloadSchema never reaches the handler at all. Signature verification is the transport's job in every environment — the host ingress enforces it, the local harness logs it as skipped. Do not verify it yourself; you would be verifying a header the transport already consumed.

If a vendor's scheme is not expressible yet and you declare 'none', the delivery is authenticated only by the endpoint token in its URL. Treat the body as a hint: take the ids from it and re-fetch the item through ctx.fetch before writing anything a widget renders, and confirm removals with the vendor too. A forged delivery then costs one read instead of a planted name or link on the dashboard.

signature.scheme — when the provider is not a plain body digest

Without scheme, the transport expects the header to be an HMAC-SHA256 of the raw body, hex or base64, optionally labelled sha256= (GitHub, Linear). Most other providers differ in one of three ways: they pack the digest into k=v pairs, they sign a timestamp along with the body, or they use SHA-1. scheme describes that declaratively; the transport does the arithmetic and your handler still contains no verification code.

| Provider | scheme | |---|---| | Stripe, Calendly (t=…,v1=…, HMAC over "<t>.<body>") | { format: 'keyed', digestKey: 'v1', timestampKey: 't', signedPayload: '{timestamp}.{body}', toleranceSeconds: 300 } | | Retell (v=<unix_ms>,d=<hex>, HMAC over body + timestamp) | { format: 'keyed', digestKey: 'd', timestampKey: 'v', signedPayload: '{body}{timestamp}' } | | Intercom (X-Hub-Signature: sha1=…) | { algorithm: 'sha1' } | | Svix-style single signature (svix-timestamp header, HMAC over "<ts>.<body>") | { timestampHeader: 'svix-timestamp', signedPayload: '{timestamp}.{body}' } |

Fields: algorithm ('sha256' default, or 'sha1'), format ('digest' default, or 'keyed' for k=v,k=v headers), digestKey / timestampKey (which pairs, keyed only), timestampHeader (a separate header instead of a pair), signedPayload (a template over {body} and {timestamp}; default '{body}'), toleranceSeconds (replay window whenever a timestamp source is declared; default 300, 0 disables). Seconds and milliseconds are both accepted from the sender. defineIntegration() rejects a scheme that cannot be verified — a keyed format with no digestKey, a {timestamp} with no source, a timestamp source whose value is not in signedPayload (a replay window over an unsigned timestamp protects nothing), a transport header name such as authorization — so the mistake surfaces before the first delivery, not as a 401 on it. Linear's replay guard is a field inside the signed body, so for Linear you omit scheme and check webhookTimestamp in the handler.

Two things the scheme never does: it never takes the algorithm from the header (a sha1= label under a sha256 declaration fails), and it never lets a stale delivery through because the digest was right — freshness is checked first. Svix-style multi-signature headers (v1,<b64> v1,<b64>, space-separated with a whsec_ base64 secret) are not covered yet; declare 'none' for those and say so in the description. Slack (v0= label over v0:{ts}:{body}) is not covered either.

schedules

A cron-driven run. Same handler shape, same ctx.

import type { PartnerScheduleDeclaration } from '@ekanos/sdk/integration';

export const dailyReconcile: PartnerScheduleDeclaration<MyStorage> = {
  id: 'daily-reconcile',             // kebab-case
  description: 'Pull the latest payments each morning and store a summary.',
  cron: '0 6 * * *',

  async handler(ctx, invocation) {
    // invocation: { scheduledFor, invokedAt, trigger }
    const apiKey = await ctx.secrets.get(ACME_API_KEY);   // from src/config.ts

    if (!apiKey) {
      // Not configured is an EXPECTED state for a schedule — the account has
      // nothing to reconcile yet. Skip, don't fail.
      return { status: 'skipped', detail: 'No API key configured.' };
    }

    const response = await ctx.fetch('https://api.acme.example/v1/payments', {
      headers: { authorization: `Bearer ${apiKey}` },
    });

    if (!response.ok) {
      // An upstream failure IS a failed run — throw so the transport records
      // it and the scheduler can retry.
      throw new Error(`Acme returned ${response.status} during reconcile.`);
    }

    await ctx.storage.account.set('reconcile/daily', summarize(await response.json()));

    return { status: 'completed', detail: 'Reconciled 214 payments.' };
  },
};

completed means the run did its work; skipped means it correctly did nothing. A failure throws.

trigger distinguishes the scheduler from a human pressing "Run now" (in the harness, or an admin in the host). Handlers may branch on it — skipping an idempotency window for a manual run, say — but must be safe under both, and under redelivery. The example above recomputes from the API and overwrites one row, so running it twice is harmless. Aim for that.

Cron syntax. Standard 5 fields, minute hour day-of-month month day-of-week, validated by defineIntegration() against the same grammar the host scheduler uses. Numeric values only, with *, lists (1,15), ranges (1-5) and steps (*/15, 0-30/5). Bounds: minute 0-59, hour 0-23, day-of-month 1-31, month 1-12, day-of-week 0-7 (0 and 7 both Sunday). No names (JAN, MON), no @daily macros, no seconds field.

onActivate (activation)

The gap this closes: the sanctioned widget-data path is schedule → writes clientReadable storage → widget reads it, and nothing server-side runs at connect time — so a cache-backed dashboard is EMPTY until the first schedule tick (up to a full interval), and stays STALE after an activation-data change until the next tick. onActivate is the fix: a hook that runs server-side (a) once after an activation is first persisted, and (b) again after activationData is updated.

import type { OnActivateHandler } from '@ekanos/sdk/integration';

export const seedOnConnect: OnActivateHandler<MyStorage> = async (ctx) => {
  const apiKey = await ctx.secrets.get(ACME_API_KEY);
  if (!apiKey) return; // Nothing to seed yet — not an error.

  const response = await ctx.fetch('https://api.acme.example/v1/summary', {
    headers: { authorization: `Bearer ${apiKey}` },
  });
  if (!response.ok) {
    throw new Error(`Acme returned ${response.status} while seeding the cache.`);
  }

  await ctx.storage.account.set('cache/summary', await response.json());
};

Same ctx, same enforcement (egress, storage, secrets) as schedules[].handler.

v1 errors are NON-FATAL. A throw is logged and shown to the user as a warning; the activation stays connected. This hook is for cache seeding and eager validation, not a connect gate — it cannot reject a connection. A fail-the-connect credential validator is a deliberately separate, not-yet-built field; do not repurpose onActivate as one.

Pair it with a fingerprint, or the cache still goes stale. A cache invalidated only by age keeps serving the previous activation's values after (b) fires, until whatever onActivate writes lands. Store a fingerprint of the activation fields a cached value depended on — every field that changes the values, not just the ones that select which data to fetch (units, currency and locale leave a cache key untouched while making every number wrong) — alongside the value, and treat a mismatch (or an absent fingerprint) on read as a cache miss. Declare a new fingerprint field on a storage schema as .optional(): a required field throws StorageValidationError on every pre-existing row, which for a clientReadable key reaches the widget.

oauth

You declare the provider; the transport owns the flow. Authorize redirect, state, callback and code exchange are not yours to write. You name the endpoints, name the client-credential secrets, and persist the tokens.

import type { PartnerOAuthDeclaration } from '@ekanos/sdk/integration';

export const acmeOAuth: PartnerOAuthDeclaration<MyStorage> = {
  provider: {
    authorizationUrl: 'https://connect.acme.example/oauth/authorize',
    tokenUrl: 'https://connect.acme.example/oauth/token',
    scopes: ['payments:read', 'refunds:write'],
    pkce: true,
    // How the token endpoint wants client authentication. Defaults to
    // 'client_secret_basic' (HTTP Basic); LinkedIn needs 'client_secret_post'
    // (client_id and client_secret in the form body).
    tokenAuthMethod: 'client_secret_basic',
  },
  credentials: {
    clientIdSecretName: 'acme_oauth_client_id',
    clientSecretSecretName: 'acme_oauth_client_secret',
  },

  // Called after the code exchange, and again after every refresh.
  async onTokens(ctx, tokens) {
    // tokens: { accessToken, refreshToken?, expiresAt?, scope?, tokenType?, raw? }
    await ctx.secrets.set('acme_access_token', tokens.accessToken);

    if (tokens.refreshToken) {
      await ctx.secrets.set('acme_refresh_token', tokens.refreshToken);
    }

    ctx.logger.info(
      { scope: tokens.scope ?? null, expiresAt: tokens.expiresAt ?? null },
      'Stored Acme Connect tokens.',
    );
  },
};

Token storage policy is just the capability layer — ctx.secrets.set() writes to the account tier, nothing new to learn.

Both endpoint origins must be covered by your egress list. defineIntegration() rejects the declaration otherwise, with the exact origin to add. The code exchange runs through your declared egress, so an origin the reviewer never saw is an origin the exchange must not reach.

raw on the token set carries provider-specific extras verbatim, for providers that return more than the standard fields.

The redirect URI exists, but you do not declare it. The schema is .strict(), so provider.redirectUri is a hard error rather than an ignored key — and that is correct rather than an omission: the host owns the callback route and builds the URI from its own origin, so a value you declared would either be ignored or aim the host's callback somewhere it should not go.

Register this with your provider, against whichever host will run the flow:

https://<your-ekanos-host>/api/integrations/<your-slug>/callback

The dev harness serves its own loopback callback, so you can complete a real round trip locally in live mode: register http://localhost:3100/api/integrations/<your-slug>/oauth/callback with your provider as well. See "Local OAuth" in the @ekanos/harness README for the secrets file and the connect flow.

In live mode the harness runs this loop for real on localhost — authorize redirect, single-use state, code exchange through your declared egress, then your onTokens. Client credentials come from .ekanos/local-secrets.json, which the harness generates and ignores in version control. In fixtures mode it answers connection status from the harness entry instead, and you can still unit-test onTokens directly against createMockContext() — it is an ordinary async function.

proposes

Where you declare your credential model and classify each tool. Once your integration is admitted the host applies these values, the same as a first-party integration's own. The admission review is the check, and the host can tighten a declaration (never loosen it).

proposes: {
  // 'account' | 'user' | 'source'. Declaring 'account' asserts that no
  // handler reads a personal grant or a session client.
  credentialModel: 'account',

  tools: {
    list_recent_payments: { effect: 'read',  sensitivity: 'financial' },
    // Honest classification: this moves money. Marking it 'read' or
    // 'internal' to get past a gate is a review failure at admission.
    refund_payment:       { effect: 'write', sensitivity: 'financial' },
  },
},

effect is 'read' | 'write'. sensitivity is 'public' | 'internal' | 'pii' | 'financial'. Classify every tool: a field you leave out falls back to effect: 'write' and sensitivity: 'pii', and a tool treated that way will not run in a team workflow.

The keys of proposes.tools must match declared tool names exactly — a proposal for a tool you did not declare is a validation error, so a rename cannot leave a stale classification behind.

What the host does with it:

  • credentialModel decides which workflows may call your tools. Team workflows (a machine actor) admit 'account' and 'source'; personal workflows (the user) admit 'user' and 'account'. Undeclared means workflows cannot use your tools.
  • Team workflows never expose pii or financial tools, and a write tool needs an approval step in the workflow. Agents use their per-tool approval policy. Personal workflows and the chat assistant apply no sensitivity gate.
  • ctx.storage.user still throws for machine actors, and partner OAuth tokens stay account-scoped.

workspaceTargets

The named dashboard workspaces your integration ships with — the sidebar section its widgets live in.

If you declare components.widgets, you must declare at least one workspace target. The admission gate rejects a submission that declares widgets with an empty or absent workspaceTargets (widget-workspace/no-home, an error-level finding). The reason is mechanical rather than stylistic: a widget reaches a dashboard through your integration's inheriting workspace attachment, which the host builds from a declared target. With no target your integration is absent from the placement map, so every widget you declared renders nowhere until an administrator places it by hand into somebody else's workspace.

One target is enough. slug (kebab-case — it is the /workspace/<slug> URL segment) and name (what the sidebar shows) are required. inheritsWidgets: true is the usual choice for a canvas-only integration: it is what makes this workspace receive the widgets you declared. icon, description and layout ('masonry' — the default — or 'two-column') are optional.

workspaceTargets: [
  {
    slug: 'acme-payments',
    name: 'Payments',
    description: 'Settlements, refunds and anything needing attention.',
    inheritsWidgets: true,
  },
],

At most one target may set inheritsWidgets, and a target that sets includedWidgetIds (limiting itself to named widgets) may not also inherit. The WorkspaceTargetDefinition type is exported from the root entrypoint.

Declaring no widgets at all is fine, and then workspaceTargets is optional too. What is not accepted is widgets with nowhere to put them.

Testing

@ekanos/sdk/testing gives you the same context production builds, in memory: same schema validation, same egress allowlist, same secret tier rules, no network.

import { createMockContext } from '@ekanos/sdk/testing';

const ctx = createMockContext({
  integration: { slug: 'acme-payments' },
  storageSchemas: myStorage,
  egress: integration.egress,
  storage: { account: { config: { merchantId: 'mrc_4820193' } } },
  secrets: {
    account: { acme_api_key: 'acme_test_9f2c41ab' },   // writable via secrets.set()
    admin: { platform_key: 'not-writable' },           // set() here throws SecretAccessError
  },
  fetchHandlers: [
    // `match` as a string matches URLs that START WITH it; a RegExp is tested
    // against the whole URL. An allowed call that matches nothing resolves 200 {}.
    { match: 'https://api.acme.example', respond: () => Response.json({ payments: [] }) },
  ],
});

const result = await integration.tools[0].run(ctx, {});

expect(ctx.fetchCalls).toHaveLength(1);         // { url, init, denied }[]
expect(ctx.logs.at(-1)?.level).toBe('info');    // { level, context, message }[]
expect(ctx.dumpStorage().account).toMatchObject({ /* … */ });

invokeWebhook() and invokeSchedule() do the same for the event surfaces, and they build the context from the definition — its slug, storage schemas and egress — so a handler can never be tested against schemas or an allowlist it does not declare.

import { invokeSchedule, invokeWebhook } from '@ekanos/sdk/testing';

const { result, event, ctx } = await invokeWebhook(integration, 'payment-updated', payload, {
  contextOptions: { storage: { account: { config } }, fetchHandlers: [...] },
  headers: { 'x-acme-signature': 'ignored — the transport verifies, not you' },
});

expect(result.status).toBe('processed');

// Reuse one context across invocations so state accumulates, as it does in a
// real account. Pass `context` OR `contextOptions`, never both.
const second = await invokeSchedule(integration, 'daily-reconcile', {
  context: ctx,
  trigger: 'schedule',       // defaults to 'manual'
});

Both throw if the id is not declared, and invokeWebhook throws if the payload fails payloadSchema — the handler never runs. The local transport records the signature skip as one warn line on ctx.logs.

invokeWebhook never verifies a signature — that is the transport's job — so a wrong header name or signed-payload template in your declaration would first show up as rejected deliveries on the host. verifyDeclaredWebhookSignature() closes that gap: it runs the host's own verifier against one declaration. Sign a payload in the test the way the VENDOR documents it and assert the declaration accepts it:

import { createHmac } from 'node:crypto';
import { verifyDeclaredWebhookSignature } from '@ekanos/sdk/testing';

const body = JSON.stringify(payload);
const t = String(Math.floor(Date.now() / 1000));
const v1 = createHmac('sha256', secret).update(`${t}.${body}`).digest('hex');

await expect(
  verifyDeclaredWebhookSignature(integration, 'payment-updated', {
    headers: { 'Stripe-Signature': `t=${t},v1=${v1}` },
    rawBody: body,
    secret,
  }),
).resolves.toEqual({ ok: true });

A failure reports the same reason the host would log (missing-header, malformed-header, missing-timestamp, malformed-timestamp, stale-timestamp, missing-secret, mismatch). An empty secret fails closed with missing-secret. It throws for an undeclared id and for a declaration with signature: 'none'. The verifier itself is also exported as verifyWebhookSignature() for a test that wants to drive it directly.

invokeActivate() does the same for onActivate — no payload or invocation to build, just the handler against the definition-derived context:

import { invokeActivate } from '@ekanos/sdk/testing';

const { ctx } = await invokeActivate(integration, {
  contextOptions: { secrets: { account: { acme_api_key: 'test_key' } } },
});

expect(ctx.dumpStorage().account['cache/summary']).toBeDefined();

Throws if onActivate is not declared, or if the handler itself throws — this helper is a bare invoker, not the host. The v1 non-fatal handling (log-and-continue) is applied by whatever calls the hook in production or in the harness, so a test asserting that behavior should catch the rejection itself.

Entrypoints

| Import | Runs on | Contents | |---|---|---| | @ekanos/sdk | anywhere | Types only — the integration contract (IntegrationMetadata, component prop types, activation/health/data types) and the capability context types (IntegrationContext, ScopedStore, StorageSchemas, …) | | @ekanos/sdk/components | client | Widget compound API + WidgetContext, WidgetPreviewProvider, BaseActivationForm, OAuthActivationForm, BaseMarketplaceTile, AiPromptChip | | @ekanos/sdk/hooks | client | useActivateIntegration/useDeactivateIntegration (host-injected actions via IntegrationActivationProvider), useOAuthConnectionStatus, fetchIntegrationStorage, widget event bus, useAskAssistant | | @ekanos/sdk/context | anywhere (isomorphic, dependency-pure) | EgressDeniedError, SecretAccessError, StorageValidationError, the isEgressAllowed matcher, the storage validators — one implementation shared by the production wrapper and the mock | | @ekanos/sdk/testing | anywhere (tests) | createMockContext(), invokeWebhook(), invokeSchedule() | | @ekanos/sdk/integration | anywhere (isomorphic, dependency-pure) | defineIntegration(), validateIntegrationDefinitions(), IntegrationDefinitionSchema, and every Partner*Declaration type |

@ekanos/sdk/mcp is not in the published package. It is excluded from the tarball (files carries !dist/mcp) and dropped from publishConfig.exports, so importing it from an installed @ekanos/sdk will not resolve. Tools are declared with tools[] on your definition, documented above — you do not need this entrypoint to write one.

The root entrypoint is types-only and must stay importable from client code. /context, /testing and /integration are self-contained by construction and carry no server-only marker; the pack test typechecks all of them in a clean room.

Partners never extend a base class. defineIntegration() is the v1 contract, and the host adapts the definition internally.

Styling

@ekanos/ui components emit Tailwind class strings against Ekanos's semantic tokens, and icons are Font Awesome glyphs the host loads. Import @ekanos/ui/styles.css (or the narrower tokens.css / theme.css / base.css) to get the token layer — that also draws a □ placeholder for icons if you load no Font Awesome at all, so "I forgot the icon font" doesn't look identical to "my code is broken". If the host DOES load Font Awesome but not the specific glyph you asked for (Free-only host, Pro-only name), the icon renders a circle-question disc instead, and @ekanos/ui/icon warns once per name in development. See @ekanos/ui's README ("Icons: you must supply Font Awesome") for the Free-vs-Pro gap and how to size and re-family icons.

Naming and validation rules, in one table

| Field | Rule | Example | |---|---|---| | slug | kebab-case, ^[a-z0-9]+(-[a-z0-9]+)*$ | acme-payments | | version | semver, prerelease/build suffix allowed | 1.0.0, 2.1.0-beta.3 | | components.widgets[].id | kebab-case, globally unique — prefix with your slug | acme-payments-summary | | components.widgets | if non-empty, workspaceTargets must declare at least one target | — | | workspaceTargets[].slug | kebab-case, the /workspace/<slug> URL segment | acme-payments | | tools[].name | ^[a-z][a-z0-9_]*$ — lowercase snake_case | list_recent_payments | | webhooks[].id | kebab-case | payment-updated | | schedules[].id | kebab-case | daily-reconcile | | schedules[].cron | 5-field numeric cron | 0 6 * * * | | storage keys | [a-z0-9_-]+ with at most one / | settings/location | | egress[] | https origin, optional one leading *., optional port | https://*.acme.example | | OAuth endpoints | absolute https, no embedded credentials, origin in egress | — | | onActivate | must be a function (ctx) => Promise<void> | — | | examplePayload, outputExample | plain JSON only | — |

Duplicate widget ids, tool names, webhook ids or schedule ids inside one definition are rejected. Collisions across definitions, and against first-party ids, are caught by validateIntegrationDefinitions() at registration.

Rough edges we know about

Written down rather than papered over, because hitting one and not knowing whether it is you or us costs an afternoon.

  • There is no client-side ctx.fetch. ctx.fetch is server-side only. A widget that talks to a vendor API straight from the browser has nowhere to get an allowlisted fetch, so it needs a small seam of its own — a React context whose default is the browser's fetch. The @ekanos/harness README has the whole ~20-line pattern; the dev harness's live mode mounts it for you.
  • onActivate errors are non-fatal. The host runs it after an activation is first persisted and again when activation data changes, but a throw is logged and the activation stays connected — use it to seed caches and validate eagerly, never as a connect gate. It also runs in the @ekanos/harness activation surface and under invokeActivate().
  • useActivateIntegration() is inert without the host's provider. It reads server actions out of IntegrationActivationProvider. Nothing warns you at build time.
  • Storage schema evolution is manual. A stored value that no longer matches its schema throws on read. Version the key or widen with z.union.
  • ctx.storage.user throws for machine actors. Anything reachable from a workflow should use the account scope.
  • isPinnable on a widget declaration renders nothing by itself. It reaches WidgetContext as meta.isPinnable, with the value at state.pinned and a working actions.togglePinned() — but no shipped Widget.* component draws a pin control, and neither does the dashboard, which drives pinning from outside the widget. A pin inside your widget is yours to build from those three.
  • Widget sizing is declared twice, in two unrelated shapes. layouts here drives the real dashboard; the dev harness reads its own width instead. Neither validates the other.
  • **Your slug is written in