@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
- The shape of an integration
- The capability context (
ctx) - Declarable surfaces
storage— schema-validated per-account stateegress— the outbound allowlist- Secrets
tools— assistant / MCP toolscomponents.widgets— dashboard widgetscomponents.marketplaceTilecomponents.activationFormcapabilitiesandpermissionswebhooksschedulesoauthproposes— your credential model and tool classificationsworkspaceTargets
- Testing
- Entrypoints
- Naming and validation rules, in one table
- Rough edges we know about
- Publish pipeline
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 initekanos 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 zoddefineIntegration()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, andinvokeWebhook()/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 yourselfIt 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.useris 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:clientReadableworks 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=userlater 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 onlyset() 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
inlinethrough. 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.
activateIntegrationtakes whateveractivationDataobject you post, and every field whose value is a string becomes readable in your server handlers asctx.secrets.get('<that same field name>')and appears inctx.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
configDataon theActivationResultyou handonSuccess, keyed by thesettings/*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".useActivateIntegrationneeds the host's injected actions. It reads them fromIntegrationActivationProvider, 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>/callbackThe 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:
credentialModeldecides 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
piiorfinancialtools, and awritetool 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.userstill 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.fetchis 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'sfetch. The@ekanos/harnessREADME has the whole ~20-line pattern; the dev harness's live mode mounts it for you. onActivateerrors 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/harnessactivation surface and underinvokeActivate().useActivateIntegration()is inert without the host's provider. It reads server actions out ofIntegrationActivationProvider. 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.userthrows for machine actors. Anything reachable from a workflow should use the account scope.isPinnableon a widget declaration renders nothing by itself. It reachesWidgetContextasmeta.isPinnable, with the value atstate.pinnedand a workingactions.togglePinned()— but no shippedWidget.*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.
layoutshere drives the real dashboard; the dev harness reads its ownwidthinstead. Neither validates the other. - **Your slug is written in
