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

@zapier/kitcore

v0.21.1

Published

Plugin framework for building SDKs.

Readme

@zapier/kitcore

A TypeScript framework for building plugin-driven SDKs.

kitcore lets you assemble an SDK out of small, independent units called plugins. A plugin is shaped like an ES module: it has an identity, declares the other plugins it imports, and exports named members. You compose plugins by importing and re-exporting them, and createSdk materializes the whole graph into a finished, fully typed SDK object.

The design goals:

  • Fully typed authoring. Inside a method, both its private state and its imports are inferred with no manual annotations.
  • Introspectable without running anything. A plugin's contract (its methods, input schemas, output modes, metadata) is plain data, so a CLI, MCP server, or docs generator can walk it without invoking your code.
  • Low ceremony. No base classes, no decorators, no DI container. You write functions and arrays.

Install

npm install @zapier/kitcore zod

zod is a peer dependency (used for input validation and schema-driven typing).

Quick start

The smallest SDK is a single method. defineMethod describes it; createSdk builds it.

import { z } from "zod";
import { defineMethod, createSdk } from "@zapier/kitcore";

const greet = defineMethod({
  name: "greet",
  inputSchema: z.object({ name: z.string() }),
  run: ({ input }) => `Hi ${input.name}`,
});

const sdk = createSdk(greet);
sdk.greet({ name: "Ada" }); // "Hi Ada"

inputSchema is optional. When present it validates the call at the boundary and types input for you, so run needs no annotations.

Plugins are modules

There are four kinds of plugin, and an SDK is just a materialized plugin:

  • Method (defineMethod) — a leaf that is a function.
  • Property (defineProperty) — a leaf that is a value.
  • Hook (defineHook) — a leaf contributing cross-cutting behavior (see Hooks).
  • Aggregate (definePlugin) — re-exports other plugins under binding names.

A plugin declares imports (an array of the plugins it depends on) and receives them as a flat imports bag, with each import bound under its own name. The two ends share the word the way ES modules do: import { x } from "y" is the declaration, x is the binding.

const transport = defineMethod({
  name: "transport",
  run: ({ input }) => fetch(input.url),
});

const getUser = defineMethod({
  name: "getUser",
  imports: [transport], // depend on transport
  run: ({ imports, input }) => imports.transport({ url: `/users/${input.id}` }),
});

Importing a plugin pulls it into the graph but keeps it private. It only becomes part of the SDK surface if an aggregate exports it.

Composing an SDK

definePlugin builds an aggregate. Its exports array decides the public surface: a leaf binds under its own name, and a nested aggregate spreads its bindings.

import { definePlugin, createSdk } from "@zapier/kitcore";

const api = definePlugin({
  name: "api",
  exports: [getUser, fetch], // surface getUser + fetch; transport stays private
});

const sdk = createSdk(api);
sdk.getUser({ id: 1 });
sdk.fetch(url);
sdk.transport; // undefined — imported by getUser, never exported

Subsetting and renaming with selectExports

selectExports is the { a, b as c } clause. It picks a subset of a module's exports and optionally renames them. It works the same on the exports side (what you re-export) and the imports side (what a body sees).

import { selectExports } from "@zapier/kitcore";

const sdk = createSdk(
  definePlugin({
    name: "users",
    // re-export getUser under its own name, and listUsers as `listAll`
    exports: [selectExports(users, "getUser", { listAll: "listUsers" })],
  }),
);
sdk.getUser({ id: 1 });
sdk.listAll();

If two different plugins try to bind the same name, kitcore throws and points you at selectExports to rename one. Composition is dependency-based, so registration order never matters.

State and effects: setup

setup is a per-build constructor. It runs once when createSdk materializes the plugin (dependencies first), may perform side effects, and returns private state handed to run as bag.state.

const counter = defineMethod({
  name: "next",
  setup: () => ({ n: 0 }), // runs once at createSdk
  run: ({ state }) => ++state.n, // same state across calls
});

const sdk = createSdk(counter);
sdk.next(); // 1
sdk.next(); // 2

setup can read imports, so state can be built from dependencies. Because state lives behind setup, several small "state plugins" can each own a slice instead of one monolith.

Per-call context and annotations

Every method run bag includes a live callContext with the invocation's callId, depth, callOrigin, and annotations. Each method invocation gets its own annotation bag; a delegated child shares its root's call ID and origin but starts with empty annotations.

Use the run bag's annotate(fields) helper for metadata learned during execution. For metadata available from raw input, a method can declare a synchronous annotator; it runs before validation, onMethodStart, and run, so its input is unknown and must be narrowed before fields are read.

const runJob = defineMethod({
  name: "runJob",
  annotator: () => ({ operationType: "write" }),
  run: async ({ input, callContext, annotate }) => {
    const worker = await selectWorker(input);
    annotate({ worker });
    return executeJob(input, callContext.annotations);
  },
});

Both forms merge into the same bag. annotator supplies fields before the method starts; annotate adds fields as the method runs.

Properties

A property is a value rather than a function. It can be a static value, or a live get re-derived on every read, with an optional setup building the state get returns. So setup + get mirrors a method's setup + run.

import { defineProperty } from "@zapier/kitcore";

// static value
const version = defineProperty({ name: "version", value: "1.0.0" });

// built once, returned live
const apps = defineProperty({
  name: "apps",
  imports: [runAction, fetch],
  setup: ({ imports }) => buildAppsProxy(imports), // once at createSdk
  get: ({ state }) => state, // returned per read
});

Use setup + get when construction is expensive and should happen once; compute directly in get (no setup) when the value is cheap and you want it fresh on each access.

Sharing state between plugins

State doesn't have to live inside one plugin. A property whose setup builds a value once becomes a small state plugin: every plugin that imports it receives the same instance, so state is shared without a global or a monolithic context object.

// A state plugin: one Map, built once at createSdk.
const cache = defineProperty({
  name: "cache",
  setup: () => new Map<string, unknown>(),
  get: ({ state }) => state, // every importer gets the same Map
});

const write = defineMethod({
  name: "write",
  imports: [cache],
  run: ({ imports, input }) => imports.cache.set(input.key, input.value),
});

const read = defineMethod({
  name: "read",
  imports: [cache],
  run: ({ imports, input }) => imports.cache.get(input.key),
});

const sdk = createSdk(definePlugin({ name: "store", exports: [write, read] }));
sdk.write({ key: "a", value: 1 });
sdk.read({ key: "a" }); // 1 — the same Map, shared across both methods

Prefer several focused state plugins over one big shared object: each owns a slice, dependents declare exactly the state they touch, and you can swap or mock one slice in tests without disturbing the rest.

Output modes

A method's output mode shapes what run returns into the public surface:

// raw (default): the surface is whatever run returns
const transport = defineMethod({
  name: "transport",
  run: ({ input }) => fetch(input.url),
});

// item: run returns the { data } envelope itself (data and nothing else),
// symmetric with list's page shape
const getApp = defineMethod({
  name: "getApp",
  output: "item",
  run: () => ({ data: app }),
});
const { data } = await sdk.getApp({ app: "slack" });

// list: run returns one page; the surface is a paginated iterable
const listApps = defineMethod({
  name: "listApps",
  output: { type: "list", adaptPage, defaultPageSize: 100 },
  run: ({ input }) => api.get("/apps", { offset: input.cursor }),
});
for await (const app of sdk.listApps().items()) {
  /* every app across pages */
}

Positional methods

By default a method takes a single options object. positional projects named input keys onto an ordered argument list for the public call, while validation, middleware, and run still see the canonical { input }.

const fetchMethod = defineMethod({
  name: "fetch",
  inputSchema: z.object({ url: z.string(), init: z.object({}).optional() }),
  positional: ["url", "init"],
  run: ({ input }) => doFetch(input.url, input.init),
});
sdk.fetch("https://example.com", { method: "GET" });

Hooks: wrap, observe, and annotate

Cross-cutting behavior lives in a hook (defineHook), a fourth kind of leaf with three faces.

wrap is targeted middleware. An entry is keyed by the import binding it wraps and receives { imports, next, input }. It must preserve the target's contract (enforced by the types), so it cannot change the public signature.

import { defineHook } from "@zapier/kitcore";

const auth = defineHook({
  name: "auth",
  imports: [transport, credentials],
  wrap: {
    transport: ({ imports, next, input }) =>
      next({
        ...input,
        headers: withAuth(input.headers, imports.credentials()),
      }),
  },
});

const retry = defineHook({
  name: "retry",
  imports: [transport, auth], // import auth so retry nests around it
  wrap: { transport: ({ next, input }) => withRetry(() => next(input)) },
});

A method that imports transport knows nothing about auth or retry; it just calls imports.transport(...) and the chain runs automatically. Nesting follows the dependency edges (retry imports auth, so it wraps around it), not registration order, and next(input) runs the next layer.

observe is fire-and-forget: onMethodStart / onMethodEnd observers that the method boundary fires around every SDK method (input carries the lifecycle context: methodName, args, durationMs, error, ...). Observers run defensively, so a throwing observer never breaks the observed call, and an observer that itself calls SDK methods does not re-trigger itself. setup gives the hook private state, delivered to each observer.

const telemetry = defineHook({
  name: "telemetry",
  setup: () => ({ events: [] as string[] }),
  observe: {
    onMethodEnd: ({ input, state }) =>
      state.events.push(`${input.methodName}:${input.error ? "err" : "ok"}`),
  },
});

annotator adds cross-cutting annotations before onMethodStart. It is a synchronous mapper over { methodName, input, state }; like method annotators, it receives raw pre-validation input. Multiple hook annotators compose in graph order, later fields win on collision, and the method's own annotator runs last. Each contributor is best-effort, so a thrown annotation error never breaks the method or suppresses other contributors.

const classifyOperations = defineHook({
  name: "classifyOperations",
  annotator: ({ methodName }) => ({ methodCategory: classify(methodName) }),
});

Hook annotators run only for outermost surface-origin calls. They are suppressed for nested and internal-origin calls, and for methods called from an observer or another hook annotator. A method's own annotator still runs for every invocation, including those suppressed cases, so method behavior does not depend on how the method was reached.

A hook joins the graph like any other plugin: import or export it from an aggregate.

Dependency injection

A plugin "names what it needs and receives it" without knowing who supplied it. That makes test doubles and configured providers trivial: register a real implementation under the same id and dependents get it transparently. This is the normal way one plugin reaches another, import the plugin, read it from the imports bag.

Configuration

Runtime values (an options object, a configured client) enter through createSdk's configuration map, keyed by plugin id. Each entry materializes as a value property with that id, satisfying a declareProperty / declareOptionalProperty stand-in — so plugins read configuration through imports like any other dependency, and tests inject doubles the same way.

import { declareOptionalProperty } from "@zapier/kitcore";

const configRef = declareOptionalProperty<"config", { pageSize?: number }>({
  id: "config",
});

const listThings = defineMethod({
  name: "listThings",
  imports: [configRef],
  run: ({ imports }) => api.list({ pageSize: imports.config?.pageSize ?? 10 }),
});

const sdk = createSdk(listThings, {
  configuration: { config: { pageSize: 50 } },
});

Extending a built SDK

addPlugin materializes a plugin into an already-built SDK in place. A TypeScript assertion-function signature widens the existing binding to include the new plugin's contributions, so you don't need a new variable.

import { addPlugin } from "@zapier/kitcore";

addPlugin(sdk, defineMethod({ name: "ping", run: () => "pong" }));
sdk.ping(); // "pong", typed

Teardown

dispose is setup's dual: a leaf that owns a resource declares one, receiving { imports, state, input }. disposeSdk(sdk, input?) runs every disposer in reverse build order (dependents before their dependencies) and is idempotent; input is forwarded to each disposer (e.g. { exitCode } from a CLI shutdown).

import { disposeSdk } from "@zapier/kitcore";

const emitter = defineProperty({
  name: "emitter",
  setup: () => createEmitter(),
  get: ({ state }) => state,
  dispose: ({ state }) => state.close(),
});

// at shutdown
await disposeSdk(sdk, { exitCode: 0 });

Introspection

getRegistry(sdk) reports the live surface as plain data (one entry per binding, with the leaf's metadata), which is what drives generated docs, CLI commands, and MCP tools. It needs nothing on the SDK itself.

import { getRegistry } from "@zapier/kitcore";

const sdk = createSdk(definePlugin({ name: "api", exports: [greet] }));

const registry = getRegistry(sdk);
registry.functions; // [{ name, description, inputSchema, ... }]
registry.categories; // grouped for menus / docs

Re-export the built-in getRegistryPlugin if you also want a getRegistry() method on the SDK surface for your own callers. It is a thin shim over the same result, so the two agree. Ids are scoped to a graph, so separate SDKs in one process can each register it; what cannot work is one graph pulling in two copies of kitcore, whose built-ins would then collide on the shared kitcore/getRegistry id. Introspection does not depend on the re-export either way.

The result is memoized per SDK and per package filter, and addPlugin drops it, so a registry read still reflects anything added after the build. Because every reader shares that one object, its functions and categories arrays are frozen: copy before sorting or filtering in place.

That holds for an SDK with a kitcore context, which is anything createSdk built. An object without one that still surfaces its own getRegistry(), such as a test stub or a wrapper projecting someone else's registry, has no memo to share: getRegistry(sdk) forwards to that accessor and hands back whatever it builds, fresh and mutable on every call.

Resolving inputs: controllers

A method's inputSchema says what a complete call looks like, but a caller often starts with only part of it. A resolution controller is a sibling layer over a built SDK that turns a partial input into a complete, validated one, asking a host (a CLI prompt, a web form, an agent) for each missing piece only when it has to. The SDK surface is untouched; the controller reads its schemas and resolvers and drives them.

You make a parameter resolvable by attaching a defineResolver to it on the method. A resolver can offer a static enum (derived from the schema), fetch a dynamic list (with pagination and search), resolve without a prompt (a configured default), or describe an object/array to fill in field by field.

import { defineMethod, defineResolver, createSdk } from "@zapier/kitcore";

const appResolver = defineResolver({
  // a paginated, searchable picker
  listItems: ({ input, search, cursor }) => api.listApps({ search, cursor }),
  prompt: ({ items }) => ({
    message: "Which app?",
    choices: items.map((a) => ({ label: a.title, value: a.key })),
  }),
});

const runAction = defineMethod({
  name: "runAction",
  inputSchema: z.object({ app: z.string(), action: z.string() }),
  resolvers: { app: appResolver },
  run: ({ input }) => api.run(input),
});

createController(sdk) exposes two faces over the same engine.

Wizard face: resolve

resolve is in-process sugar: it loops the resolution against an answer callback and returns the finished input. The callback's only job is to render a question and return the chosen action; all resolution logic (fetching, pagination, search, validation, nesting) stays in the engine.

const controller = createController(sdk);

const input = await controller.resolve({
  method: "runAction",
  input: { action: "send_message" }, // seed what you already have
  answer: async ({ state, result }) => {
    // render result.question (a select / input / collection) however you like,
    // then map the user's choice back to an action:
    return { type: "choose", value: "slack" };
  },
});

await sdk.runAction(input); // complete + validated

Serializable protocol: start / step

For a host that spans a client/server boundary (web, agent, MCP), drive the protocol directly. start returns the first { state, result }; you render result, then send the user's action plus the state back through step. state is plain JSON, so it round-trips over the wire with no live objects to keep alive between turns.

let { state, result } = await controller.start({ method: "runAction" });
while (result.status === "ask") {
  const action = await renderAndCollect(result.question); // your host
  ({ state, result } = await controller.step({ state, action }));
}
// result.status is now "done" | "invalid" | "cancelled"

Reflection face: describe / listChoices

For a form-style host that renders every field up front instead of one question at a time, describe returns the static shape of a method's inputs (required-ness, value type, which are dynamic, their dependencies), and listChoices enumerates the legal values for one dynamic parameter.

controller.describe({ method: "runAction" });
// { app: { required: true, dynamic: true }, action: { required: true, ... } }

await controller.listChoices({
  method: "runAction",
  parameter: "app",
  search: "sl",
});
// { data: [{ label: "Slack", value: "slack" }], nextCursor }

Plugin references

A plugin references a capability supplied elsewhere by its id, without providing it. Use declareMethod / declareProperty for a single leaf, or declarePlugin for a whole module (its export surface declared as leaf references). A reference binds, at createSdk, to whatever real plugin is registered under the same id. You reference by id (namespace/name, or a bare name), with the contract as explicit type arguments (declareMethod<"fetch", Input, Output>({ id: "fetch" })), because the id must be a literal the dependency ledger can read; the binding is the id's last segment.

Two flavors, by what happens when nothing provides the id:

  • Required (declareMethod / declareProperty): an unsatisfied reference is a compile-time error (with a runtime backstop). Use when the capability must be present.
  • Optional (declareOptionalProperty / declareOptionalMethod): binds undefined instead of failing, so the consumer handles absence in code ({ ...DEFAULTS, ...imports.config } for a value, imports.track?.(...) for a method). Use to reference a foreign capability userland may or may not import, without claiming its slot.

The contract is checked, not just the id

createSdk checks that each id's providers match the contract the reference declared, not only that a provider exists. The comparison is on the call a consumer makes, so a provider may accept wider input and must return a subtype of the declared output. A provider written from scratch is checked the same as one written against the reference.

const doubleRef = declareMethod<"double", { value: number }, number>({
  id: "double",
});

// Rejected at createSdk: this author wrote `double` for a display surface, so
// it formats instead of computing. The declaration promised a number.
defineMethod({
  name: "double",
  run: ({ input }: { input: { value: number } }) => `${input.value * 2}`,
});

// Also rejected: an `item` method is a fine plugin, but the declaration asked
// for a bare number, not an envelope.
defineMethod({
  name: "double",
  output: "item",
  run: async ({ input }: { input: { value: number } }) => ({
    data: input.value * 2,
  }),
});

// Accepted.
defineMethod({
  name: "double",
  run: ({ input }: { input: { value: number } }) => input.value * 2,
});

Two modules declaring one id independently is the intended use, and stays legal while they agree. They are caught when they disagree, because no single provider can satisfy both. Every reachable provider under an id must honor the contract, so a sound declareDefault beside an unsound explicit provider is still rejected: the explicit one is what the runtime picks.

An optional reference is checked the same way when a provider does appear. Its contract is the binding a consumer sees, so undefined is part of it: a consumer of an optional reference handles the absent case regardless, and a by-reference provider is allowed to pass undefined explicitly.

A module reference (declarePlugin) is checked through its export references, leaf by leaf, so a module method typed differently from its reference is caught. What is NOT checked is whether the real module exports everything the reference promised: only the module's own id is a requirement, so a promised export the module lacks still fails at runtime.

Spell the contract out. An id-only declareMethod<"double">({ id: "double" }) defaults to unknown on both sides. That makes it over-strict on input, since a consumer may call it with no argument and any provider needing real input is rejected, and vacuous on output, since every provider returns a subtype of unknown. No declare* call opts out of the compatibility check.

What the graph checks do NOT reach. A defineHook carries no summary, so a declaration reachable only through a hook's imports is checked by neither ledger. addPlugin performs no graph checks at all. And a positional surface cannot be declared by id: declareMethod describes an object call only, so a positional provider is correctly rejected against one, and referencing a positional method means casting past the ledger (curl does exactly that for fetch). Those three are limitations, not design.

If you annotate a plugin by hand rather than letting define* infer it, pass the contract. LeafSummary takes the surfaced binding as a fourth argument (LeafSummary<"", "setup", typeof imports, MethodContract<SetupOptions, Promise<void>>>), and a reference is DeclarationSummary<TId, TBinding> or OptionalDeclarationSummary<TId, TBinding>. A PluginSummary written with two arguments still compiles and declares no contract, so a hand-annotated plugin drops out of the compatibility check silently.

Defaults

When you own a capability slot and can ship a working implementation, register it as a default with declareDefault({ plugin }) rather than referencing it. A default is a real, single node in the graph, so it works out of the box, hooks and middleware can wrap it, and an explicit provider of the same id silently preempts it (that is the seam for userland or another plugin to replace it).

import { declareDefault } from "@zapier/kitcore";

const httpPlugin = defineMethod({
  name: "http",
  // Ship a working default pipeline stage; userland can wrap or replace it.
  imports: [declareDefault({ plugin: defaultAuthStagePlugin })],
  run: ({ imports }) => imports.authStage(/* ... */),
});

Default versus optional reference is an ownership question:

  • Own the slot → default. It is yours to provide, so provide the fallback.
  • Don't own it → optional reference. Point at it without claiming it.

The collision rules follow from ownership: two plugins defaulting one id to different implementations conflict (both claimed the slot), while two optional references to the same foreign id never conflict (neither did). So a default on an id you do not own is a latent conflict, triggered the moment a second plugin does the same.

Namespaces

Plugin ids are name by default, or namespace/name when you set the optional namespace field. Namespacing lets two packages define a plugin with the same bare name without their ids colliding; the surface and imports stay bare-named, so consumers are unaffected.

const greet = defineMethod({
  name: "greet",
  namespace: "acme",
  run: () => "hi",
});
// id is "acme/greet"; sdk.greet() is still the call

Status

Pre-release. The public surface is being stabilized as part of an extraction from @zapier/zapier-sdk. Until a 1.0.0 release, expect breaking changes between minor versions.