Skip to content
mycl is in early development. The API may change before a stable release; it is not yet recommended for production use.

The Core Substrate

mycl is one package, @mycl/core, with five entry points. The main entry is the everyday surface: createFnChannel hands you a kernel with the factory ergonomics (mycl, scope, requires) already wired, plus registry and the guards, all in one import. The subpaths are the substrate: the mechanism layer underneath, the part that knows how a capability dispatches, how the layer fold runs, and how the type contracts hold, and nothing about when code runs or how a scope crosses an await.

Most people import the main entry and never touch a subpath directly. Reach for one when you are building a build plugin, tooling that inspects registries, or a kernel with an execution model of its own: anything that needs the mechanism alone.

The line is mechanism versus policy, drawn at the entry boundary rather than a package boundary. The main entry ships the everyday policy already decided (the stack context, wired through createFnChannel). The substrate subpaths ship only mechanism: which context is active, and how a scope propagates across an async boundary, is policy, and policy lives in your kernel: the module where you mint your channel and pick its context. The stack context is the simplest policy; a different kernel can pick a different one (an AsyncLocalStorage-backed context via alsContext, a React context, a test harness) on the same substrate. See Building a Kernel.

The unit a kernel wires is a channel, and the thing that wires it is a connector: createChannel(name, connector) hands the name to the connector, mints the channel over the connector’s fresh context, and returns the kernel the connector builds from the channel’s surface:

import { stackContext } from '@mycl/core/context';
import { createChannel } from '@mycl/core/factory';
import type { ChannelSurface } from '@mycl/core/factory';
// The minimal (identity) connector: fresh stack context, surface passthrough.
const bare = <G extends string>(_name: G) => ({
context: stackContext(),
build: (surface: ChannelSurface<G>) => surface,
});
const { capable, snapshot, channel, context } = createChannel('app', bare);
surface fieldwhat it is
capablethe capability constructor: mints capabilities that dispatch through this channel
snapshotcaptures this channel’s active scope for replay across an async boundary
channelthe channel token: pass it to setChannelContext to swap the backing context later
contextthe channel’s live ScopeContext ({ get, run }): use when you need run directly

The connector behind createFnChannel is the everyday one: it builds { channel, capable, snapshot, mycl, scope } from this surface (keeping the raw context private). That is all a kernel is: a connector’s build applied to a channel’s surface.

@mycl/core ships five entry points. Import from the narrowest one you need: the introspection and factory surfaces stay out of the dispatch critical path.

entrywhat it carries
@mycl/core (main)the everyday kernel: createFnChannel, registry, requires, setChannelContext, isCapability, isResolvedRegistry, and the full capability/registry type vocabulary
@mycl/core/helpersaugmentation helpers: before, after, pipe, handleError
@mycl/core/contextScopeContext implementations: stackContext (synchronous) and alsContext (AsyncLocalStorage-backed, Node 20.16+)
@mycl/core/factorythe build-plugin and connector-author contract: createChannel, merge, Connector, ChannelSurface, foldBindings, resolveContext, defineInternal
@mycl/core/introspectread-only describe / explain / diff over a registry

merge lives on /factory, with the kernel authors who call it directly: channel users compose by passing registries to mycl/scope, which run the composition themselves. createChannel lives on /factory too, one level above createFnChannel, which is exactly one application of it.

A registry is immutable and its bindings are inspectable. The @mycl/core/introspect subpath answers “what does this composed registry bind, and what would a capability resolve to?” without invoking anything.

Import the trio as a namespace. describe collides with the Vitest/Jest global, and these functions are used most inside tests, so the namespace form keeps them unambiguous:

import * as introspect from '@mycl/core/introspect';
introspect.describe(reg);
// [{ identity: 'my-app-or-library:db', layers: 1, augments: 0, strategy: 'last-wins' }, …]
introspect.explain(reg, db);
// { identity: 'my-app-or-library:db', bound: true, resolvesToBase: false, layers: 1, augments: 0, strategy: 'last-wins' }
introspect.diff(before, after);
// { added: ['my-app-or-library:log'], removed: [], changed: [] }

explain(reg, cap) reports what cap resolves to (base vs. how many layers/augments) without calling it, so a test can assert an override took: expect(introspect.explain(reg, cap).resolvesToBase).toBe(false). diff keys changed on a structural signature (layer count + augment count), so it catches added or removed contributions but not a changed value at the same count.