Modules & Composition

Modules are composable step groups that merge their step types into the parent skill’s store. They let you extract a reusable sequence of steps — authentication, onboarding, data gathering — and plug it into different skills. Module steps cannot access the parent skill’s params, which enforces portability: a module that works in one skill works in any skill.

Most composition needs are met by shared steps + .extend(). Modules are for groups of steps that are reused across skills and carry their own state.

module() configuration

module(config) returns a ModuleBuilder. The configuration shape is smaller than a skill — modules have no params and no observers.

import { module, type } from '@contentful/skill-kit';

export const authModule = module({
  name: 'auth',
  entry: 'auth-login',
})
  .step('auth-login', {
    /* ... */
  })
  .step('auth-verify', {
    /* ... */
  })
  .build();
FieldTypeRequiredDescription
namestringYesModule identifier. Used for error messages and debugging.
entrystringYesName of the first step in the module. Must match a registered step.

.step() within modules

Module steps work identically to skill steps. The params parameter is typed as unknown since modules are params-independent. Step results are stored in the parent skill’s store and accessible by later steps.

module({
  name: 'auth',
  entry: 'auth-login',
})
  .step('auth-login', {
    prompt: "Ask for the user's credentials.",
    response: type({ userId: 'string', password: 'string' }),
    next: 'auth-verify',
  })
  .step('auth-verify', {
    prompt: ({ store }) => `Verify credentials for user ${store.steps['auth-login'].userId}.`,
    response: type({ token: 'string', valid: 'boolean' }),
    next: '__parent__', // exits back to the registering skill
  })
  .build();

__parent__ sentinel

The __parent__ string is a special transition that means “exit this module and return to the parent skill.” When a module is registered with .register(), every step whose next is __parent__ gets rewired to the next target specified in the registration call.

A module must have at least one step with next: '__parent__' — otherwise there is no exit point. If a __parent__ sentinel survives into a built skill (not overridden by .register() or .extend()), the engine throws at startup.

.register(module, { next })

Registers a module into a skill. This does two things:

  1. Merges the module’s steps into the skill’s step map. Any step with next: '__parent__' is rewired to the provided next target.
  2. Widens the store type. After registration, subsequent steps can access module step results via the store.
import { skill, type } from '@contentful/skill-kit';
import { authModule } from './modules/auth.js';

export default skill({
  name: 'my-app',
  entry: 'welcome',
})
  .step('welcome', {
    prompt: 'Welcome the user and ask them to log in.',
    response: type({ ready: 'boolean' }),
    next: 'auth-login', // enter the module
  })

  .register(authModule, { next: 'dashboard' })

  .step('dashboard', {
    // store includes module step results — auth-login and auth-verify
    prompt: ({ store }) =>
      `Welcome ${store.steps['auth-login'].userId}. Your session token: ${store.steps['auth-verify'].token}`,
    response: type({ action: 'string' }),
    next: { terminal: true },
  })

  .build();

Registration order matters

Register the module before defining steps that depend on the module’s store entries. The type widening happens at the .register() call — steps added afterward see the merged store type.

Complete example

An authentication module registered into an application skill.

The module

// modules/auth.ts
import { module, type, act } from '@contentful/skill-kit';

export const authModule = module({
  name: 'auth',
  entry: 'auth-login',
})
  .step('auth-login', {
    prompt: act.askUser({
      type: 'structured',
      question: 'How would you like to authenticate?',
      options: [
        { value: 'github', label: 'GitHub OAuth' },
        { value: 'email', label: 'Email + password' },
        { value: 'sso', label: 'SSO' },
      ],
    }),
    response: type({ method: "'github' | 'email' | 'sso'" }),
    next: 'auth-credentials',
  })
  .step('auth-credentials', {
    prompt: ({ store }) => `Collect credentials for ${store.steps['auth-login'].method} authentication.`,
    response: type({ userId: 'string', success: 'boolean' }),
    next: [{ to: 'auth-confirm', when: ({ response }) => response.success }, { to: 'auth-login' }],
    maxVisits: 3,
    onMaxVisits: 'auth-confirm',
  })
  .step('auth-confirm', {
    prompt: act.confirm({
      message: 'Authentication successful. Continue to the application?',
      defaultAnswer: 'yes',
    }),
    response: type({ approved: 'boolean' }),
    next: '__parent__', // wired to { next } at registration
  })
  .build();

The skill

// skill.ts
import { skill, type, prompt, render, terminal } from '@contentful/skill-kit';
import { authModule } from './modules/auth.js';

export default skill({
  name: 'project-manager',
  version: '1.0.0',
  entry: 'welcome',
})
  .step('welcome', {
    prompt: 'Greet the user and introduce the project manager.',
    response: type({ projectName: 'string' }),
    next: 'auth-login',
  })

  // Register the auth module — its steps merge in, __parent__ becomes 'dashboard'
  .register(authModule, { next: 'dashboard' })

  .step('dashboard', {
    // Type-safe access to both skill steps and module steps via the store
    prompt: ({ store }) => prompt`
      Welcome back, ${store.steps['auth-credentials'].userId}!
      Project: ${store.steps.welcome.projectName}
      Auth method: ${store.steps['auth-login'].method}

      What would you like to do?
    `,
    response: type({ choice: 'string' }),
    next: terminal,
  })

  .build();

Store type widening

When you call .register(module, { next }), the builder’s store type expands to include the module’s step results. This means:

If two modules define steps with the same name, the later registration wins. Use distinctive prefixes in module step names to avoid collisions.

Three patterns for composition

Modules are the heaviest composition pattern. Here’s the full spectrum, in increasing weight:

1. Shared schemas and fragments

Plain TypeScript imports. No ceremony needed.

// shared/schemas.ts
export const CheckResult = type({ name: 'string', passed: 'boolean' });

// In any skill:
import { CheckResult } from '../../shared/schemas.js';

2. Reusable step definitions

step() creates standalone steps. Use __parent__ for the transition and .extend() to wire them in with typed overrides.

// shared/steps/gather-facts.ts
export const gatherFacts = step({
  prompt: 'Inspect the repo and list languages, build tools, CI config.',
  response: RepoFactsSchema,
  next: '__parent__',
});

// In a skill:
.extend('gather', gatherFacts, {
  prompt: ({ params }) => `Inspect ${params.repoPath}`,
  next: 'analyze',
})

3. Modules

For groups of steps that belong together, are reused across skills, and carry their own state. Use when you have a multi-step sequence (auth, onboarding, data collection) that appears in more than one skill.

4. Composite skills (sub-skills)

For independent workflows bundled into a single artifact with shared references. Sub-skills have isolated stores, their own entry points, and can be tested standalone — unlike modules which flatten into the parent. Use when you have related skills that overlap in scope (e.g., doctor + setup + FAQ under one dispatcher). See the Composite Skills guide.

Testing skills with modules

When testing a composed skill, the mockModel map uses the original step names from the module — they are merged directly into the skill’s step map.

import test from 'node:test';
import assert from 'node:assert/strict';
import { runSkill, mockModel } from '@contentful/skill-kit/test';
import skill from './skill.js';

test('auth module -> dashboard', async () => {
  const result = await runSkill(skill, {
    model: mockModel({
      welcome: { projectName: 'Acme' },
      'auth-login': { method: 'github' },
      'auth-credentials': { userId: 'user-42', success: true },
      'auth-confirm': { approved: true },
      dashboard: { choice: 'view-tasks' },
    }),
  });

  assert.deepStrictEqual(result.path, ['welcome', 'auth-login', 'auth-credentials', 'auth-confirm', 'dashboard']);
});

Module step names are not namespaced — auth-login not auth.auth-login. If two modules define steps with the same name, the later registration wins. Use distinctive prefixes in module step names to avoid collisions.