API Reference

Full reference for @contentful/skill-kit. Start with the Getting Started guide for an overview.


Workflow Builder

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

skill({ name, entry, system?, params?, stores?, observers?, finalOutput?, skillMd?, argumentHint?, allowedTools?, paths?, context? })
  .step(name, config)                       // add a step
  .extend(name, sharedStep, overrides)      // inherit + override a shared step
  .register(module, { next })               // merge module steps, widen store type
  .build()                                  // → SkillDefinition (frozen)

skill() config

FieldTypeRequiredDescription
namestringyesSkill identifier
entrystringyesName of the first step
systemstringnoSystem-level persona prepended to the preamble. Steps inherit it; steps can override with system in array prompts
versionstringnoDefaults to '0.0.0'. Mutually exclusive with resolveVersion
resolveVersiontruenoResolve version from nearest ancestor package.json. Mutually exclusive with version
descriptionstringnoUsed in generated SKILL.md
triggersstring[]noKeywords appended to description for agent discoverability
paramstype.AnynoArkType schema for immutable skill-wide params
storesRecord<string, type.Any>noNamed sub-stores with ArkType schemas. Written via save return keys, deep-merged across steps
finalOutputtype.AnynoSchema for the terminal step’s output
observersObserverMapnoLifecycle hooks (see Observers)
skillMdstring | (skill: SkillDefinition) => stringnoCustom SKILL.md template override
packagePackageConfignoFields written to the output package.json (see Package Config)
argumentHintstringnoAutocomplete hint text. Emitted as argument-hint in SKILL.md frontmatter
argumentsstring | string[]noNamed positional arguments for $name substitution in skill content. Emitted as arguments in SKILL.md frontmatter
allowedToolsstring | string[]noAdditional pre-approved tools. Build auto-includes CLI and MCP defaults; author tools are merged
pathsstring | string[]noGlob patterns for file-based auto-activation. Emitted as paths in SKILL.md frontmatter
contextstringnoExecution context (e.g. 'fork'). Emitted as context in SKILL.md frontmatter
licensestringnoLicense name or reference. Emitted as license in SKILL.md frontmatter
compatibilitystringnoEnvironment requirements. Emitted as compatibility in SKILL.md frontmatter
agentstringnoSubagent type when context: 'fork'. Emitted as agent in SKILL.md frontmatter
modelstringnoModel override while skill is active. Emitted as model in SKILL.md frontmatter
effortstringnoEffort level override. Emitted as effort in SKILL.md frontmatter
disableModelInvocationbooleannoPrevent auto-loading by the agent. Emitted as disable-model-invocation
userInvocablebooleannoWhether visible in / menu. Emitted as user-invocable

Params types flow into step callbacks automatically via contextual inference — no annotations needed.

.step(name, config)

Adds a step. Returns the builder for chaining. See Step Config for the full config shape.

.extend(name, base, overrides)

Inherits a shared step definition and overrides specific fields. The base step’s config is shallow-merged with overrides (overrides win).

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

const openQuestion = step({
  response: type({ answer: 'string' }),
  next: '__parent__',
});

// In the builder:
.extend('ask-stack', openQuestion, {
  prompt: ({ store, act }) => [
    act.askUser({ type: 'open', question: "What's your tech stack?" }),
    `Ask ${store.steps.greet.name} about their tech stack.`,
  ],
  next: 'ask-hobby',
})

.register(module, { next })

Merges all steps from a module into the skill. Module steps with next: '__parent__' are rewritten to point to next. Step types accumulate into the store — see Modules.

.build()

Validates the skill definition (entry exists, steps non-empty) and returns a frozen SkillDefinition. Throws on invalid configuration.


Step Config

The full shape of a step’s config object, passed to .step() or step():

{
  prompt?: string | PromptPiece | PromptPiece[] | PromptFn,       // optional -- omit for auto-advance steps
  response?: type.Any,                                            // optional -- omit for pass-through steps; requires prompt
  next: string | NextBranch[] | TransitionFn | { terminal: true },  // required
  action?: {
    run: ActionDefinition,
    mapInput?: (ctx: { response; store; params }) => unknown,
  },
  save?: (ctx: { response; actionResult; store; params }) => { step?; ...storeWrites },
  maxVisits?: number,
  onMaxVisits?: string,
}

PromptFn is (ctx: PromptContext) => PromptReturn, where PromptReturn = string | PromptPiece | PromptPiece[].

A PromptPiece is one of:

When a prompt function returns an array, pieces are assembled in author order.

The prompt field accepts PromptPiece directly (including ActSegment), so single-primitive steps need no wrapper function — pass the result of act.askUser(...), act.confirm(...), etc. straight to prompt.

PromptContext

Available in dynamic prompt functions:

FieldTypeDescription
storeStoreView<TSteps, TGuaranteed, TStores, TStoreWrites>Typed accessor for step results via store.steps.* and sub-store data via store.<storeName>
paramsTParamsImmutable skill params (typed from builder)
refsReferenceLoaderLoader for references/ files
attemptsnumberHow many times this step has been visited
hostHandshakeCurrent host info and available tools
actActBuilderPrimitive directive builders: askUser, confirm, plan, survey, etc.
systemSystemBuilderSystem segment tag/function for persona/frame

Prompt Types

TypeDefinitionDescription
PromptPiecestring | PromptSegmentA single element: plain text, a SystemSegment, or an ActSegment
PromptReturnstring | PromptPiece | PromptPiece[]What a prompt function may return
PromptFn(ctx: PromptContext) => PromptReturnCallback signature for dynamic prompts
PromptSegmentSystemSegment | ActSegmentTagged union of segment kinds
SystemSegment{ kind: 'system'; text: string }A system-level directive injected via system
ActSegment{ kind: 'act'; primitive: PrimitiveConfig }A primitive action injected via act.*

The Store

The store gives every step typed access to all prior step results and sub-store data. Step results live under store.steps, and sub-stores are accessed as top-level properties on store.

The SDK analyzes your workflow graph at build time and computes which steps are guaranteed to have run by the time each step executes. This flows directly into the TypeScript types: guaranteed predecessors are non-optional, branch targets require ?..

Guaranteed steps (on all paths from entry to the current step) are non-optional — direct property access:

store.steps.greet.name; // string -- guaranteed, non-optional
store.steps['ask-role'].role; // string -- guaranteed, non-optional

Branch targets (steps that only run on some paths) are optional — use ?.:

store.steps['ask-stack']?.answer; // string | undefined -- branch target
store.steps['ask-tools']?.answer; // string | undefined -- branch target

This is computed automatically from your step declarations via DAG analysis. Retry loops (backward edges to already-defined steps) don’t create false branches — the forward path is still guaranteed.

Sub-stores are accessed as top-level properties on store:

store.profile.name; // sub-store access
store.settings.theme; // sub-store access

See Sub-Stores for details on declaration and writing.

Store methods:

MethodTypeDescription
store.steps.all(step)(step: K) => TSteps[K][]All results from a step that ran multiple times (loops)
store.steps.ran(step)(step: K) => booleanWhether a step has run at least once
store.steps.historyreadonly StepResult[]Raw step records (escape hatch)

Sub-Stores

Sub-stores are named, schema-typed data bags that accumulate state across steps. Unlike step results (which are keyed by step name), sub-stores are keyed by domain concept — profile, settings, progress, etc.

Declaration — define sub-stores in the stores field of the skill config:

skill({
  name: 'onboarding',
  entry: 'greet',
  stores: {
    profile: type({ name: 'string', 'role?': 'string', 'stack?': 'string[]' }),
    preferences: type({ 'theme?': 'string', 'notifications?': 'boolean' }),
  },
});

Writing — return sub-store keys from the save callback. Keys that match a declared store name are written to that sub-store:

.step('greet', {
  response: type({ name: 'string' }),
  save: ({ response }) => ({
    step: { name: response.name },
    profile: { name: response.name },
  }),
  next: 'ask-role',
})
.step('ask-role', {
  response: type({ role: 'string' }),
  save: ({ response }) => ({
    profile: { role: response.role },
  }),
  next: 'done',
})

Deep merge — sub-store writes are deep-merged across steps. After both steps above, store.profile contains { name, role }.

Reading — sub-stores appear as top-level properties on store:

.step('done', {
  prompt: ({ store }) => `Welcome ${store.profile.name}, you're a ${store.profile.role}!`,
  next: terminal,
})

Type narrowing — the SDK tracks which sub-store fields are guaranteed to have been written by the time each step executes. Fields written on all paths from entry are non-optional; fields written only on some branches are optional. This follows the same DAG analysis used for step results.

The save callback

Steps control what gets stored via the save callback. The return object has two kinds of keys:

The priority for the step result is:

  1. Explicit save() return’s step property (if provided)
  2. Action output (if an action ran)
  3. Response (the model’s validated output)
.step('profile-card', {
  response: type({ card: 'string', profile: ProfileSchema }),
  action: { run: writeProfile },
  save: ({ response, actionResult }) => ({
    step: {
      card: response.card,
      savedPath: actionResult.path,
    },
    profile: { name: response.profile.name, avatar: response.profile.avatar },
  }),
  next: { terminal: true },
})

The agent contract (response) and the step’s contribution to state (save) are separate. response is what the model gives back. The step key in the save return is what downstream steps see in store.steps. Other keys write to sub-stores — downstream steps access them via store.<storeName>. When an action transforms the response, the store carries the transformed result — not the raw model output.

Transitions

All three branching forms (static, declarative, dynamic) participate in DAG-based type narrowing. The type system extracts branch targets from declarative NextBranch[] arrays via the to fields, and from function next via the literal return type (e.g., ({ response }) => response.ok ? 'a' : 'b' infers as () => "a" | "b", yielding targets 'a' | 'b'). Downstream steps see branch targets as optional in the store.

Loop guards

Steps in detected cycles have an implicit visit limit (10) enforced at runtime. For explicit control, declare maxVisits and onMaxVisits:

.step('ask-hobby', {
  // ...
  maxVisits: 2,
  onMaxVisits: 'confirm-profile',  // fallback when limit hit
  next: [
    { to: 'ask-hobby', when: ({ response }) => response.wantsMore },
    { to: 'confirm-profile' },
  ],
})

The cycle guard validator detects potential cycles and reports them as lint warnings. At runtime, exceeding the limit with onMaxVisits set redirects; without it, the engine throws.


Reference Builder

For skills that don’t need a workflow — just progressive disclosure of content:

import { reference } from '@contentful/skill-kit';

reference({ name, description, version?, resolveVersion?, package? })
  .topic(name, { label, content: (ctx) => string })
  .build()                                           // → ReferenceDefinition (frozen)

reference() config

FieldTypeRequiredDescription
namestringyesReference skill identifier
descriptionstringyesUsed in generated SKILL.md
versionstringnoDefaults to '0.0.0'. Mutually exclusive with resolveVersion
resolveVersiontruenoResolve version from nearest ancestor package.json. Mutually exclusive with version
packagePackageConfignoFields written to the output package.json (see Package Config)
argumentHintstringnoAutocomplete hint text. Emitted as argument-hint in SKILL.md frontmatter
argumentsstring | string[]noNamed positional arguments for $name substitution in skill content. Emitted as arguments in SKILL.md frontmatter
allowedToolsstring | string[]noAdditional pre-approved tools. Build auto-includes CLI and MCP defaults; author tools are merged
pathsstring | string[]noGlob patterns for file-based auto-activation. Emitted as paths in SKILL.md frontmatter
contextstringnoExecution context (e.g. 'fork'). Emitted as context in SKILL.md frontmatter
licensestringnoLicense name or reference. Emitted as license in SKILL.md frontmatter
compatibilitystringnoEnvironment requirements. Emitted as compatibility in SKILL.md frontmatter
agentstringnoSubagent type when context: 'fork'. Emitted as agent in SKILL.md frontmatter
modelstringnoModel override while skill is active. Emitted as model in SKILL.md frontmatter
effortstringnoEffort level override. Emitted as effort in SKILL.md frontmatter
disableModelInvocationbooleannoPrevent auto-loading by the agent. Emitted as disable-model-invocation
userInvocablebooleannoWhether visible in / menu. Emitted as user-invocable

.topic(name, config)

Registers a topic. content is a lazy function receiving { refs: ReferenceLoader }:

.topic('auth', {
  label: 'Authentication and token management',
  content: ({ refs }) => refs.load('auth.md'),
})
.topic('errors', {
  label: 'Error codes and troubleshooting',
  content: () => render.table(ERROR_CODES, { columns: ['code', 'meaning', 'fix'] }),
})

At least one topic is required. build() throws if name, description, or topics are missing.


Package Config

Both skill() and reference() accept an optional package field that controls the generated package.json:

export default skill({
  name: 'my-skill',
  resolveVersion: true,
  entry: 'start',
  package: {
    name: '@org/skill-my-skill',
    license: 'MIT',
    files: ['SKILL.md', 'scripts/**', 'bin/**'],
  },
});

PackageConfig fields

FieldTypeDescription
namestringOverride package name (default: skill name)
descriptionstringWritten to package.json
licensestringWritten to package.json
filesstring[]Written to package.json
[key]unknownAny other field is passed through to package.json

Version strategy

Version is set via one of two mutually exclusive fields (enforced at the type level):

Merge behavior

If a package.json already exists in the output directory, the build merges rather than overwrites: existing fields are preserved, package config fields override, and name/version are always authoritative.


Modules

Composable step groups that merge their step types into the parent skill’s store:

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

const authModule = module({
  name: 'auth',
  entry: 'auth-login',
})
  .step('auth-login', {
    prompt: 'Ask for credentials.',
    response: type({ userId: 'string' }),
    next: '__parent__', // exits back to the registering skill
  })
  .build();

Register into a skill — step types accumulate into the store:

skill({ name: 'app', entry: 'start' })
  .step('start', { /* ... */ next: 'auth-login' })
  .register(authModule, { next: 'dashboard' })
  .step('dashboard', {
    // store now includes auth-login results
    prompt: ({ store }) => `Welcome ${store.steps['auth-login'].userId}`,
    // ...
  })
  .build();

module() config

FieldTypeRequiredDescription
namestringyesModule identifier
entrystringyesEntry step within the module

The __parent__ sentinel in next is rewritten to the { next } value passed to .register(). Module steps that don’t use __parent__ pass through unchanged.


Composite Skills

Combine related skills into a single artifact with shared references. A composite is a regular skill() with sub-skills and topics registered on it.

.subskill(name, definition, opts?)

Register a standalone SkillDefinition as a sub-skill:

import doctorSkill from './subskills/doctor.js';

skill({ name: 'helper', entry: 'classify' })
  .step('classify', {
    response: type({ intent: 'string' }),
    next: ({ response }) => `subskill:${response.intent}`,
  })
  .subskill('doctor', doctorSkill, {
    params: (_response, store) => ({ spaceId: store.steps.spaceId }),
  })
  .build();
OptionTypeRequiredDescription
params(response, store) => unknownnoMaps dispatcher state to sub-skill params

.topic(name, config)

Register a reference topic (same as on reference()):

.topic('rate-limits', {
  label: 'API rate limits',
  content: ({ refs }) => refs.load('rate-limits.md'),
})
FieldTypeRequiredDescription
labelstringyesShort description
content(ctx: { refs: ReferenceLoader }) => stringyesContent generator

Routing from next

Any step’s next can return prefixed targets:

RedirectResult

When the engine’s next resolves to a target not in the local step map, it returns:

interface RedirectResult {
  kind: 'redirect';
  redirect: string; // e.g. 'subskill:doctor'
  completed: StepResult; // the step that triggered the redirect
  store: StoreAccessor; // dispatcher's accumulated store
}

The composite entry point handles this — the host never sees RedirectResult directly.

CLI protocol for composites

Session mode (recommended):

scripts/run --params '{...}' --session new              # dispatcher start → SessionPointer
scripts/run advance --session <id>                     # advance (agent wrote output to file)
scripts/run doctor --params '{...}' --session new      # direct sub-skill start

Stateless mode (fallback):

scripts/run --params '{...}'                           # dispatcher start
scripts/run advance --step doctor/diagnose --output .. # sub-skill advance
scripts/run doctor --params '{...}'                    # direct sub-skill start
scripts/run topics                                     # list topics
scripts/run topic rate-limits                          # load a topic

Sub-skill step names are prefixed <subskill>/<step> at the protocol layer.

SessionPointer

Returned by --session new on start:

interface SessionPointer {
  sessionId: string; // 8-char hex ID
  file: string; // path to the JSONL session file
  line: number; // line number to read for the first prompt
}

See Architecture — Session mode for the full session lifecycle.

Testing composites

import { runComposite, mockModel } from '@contentful/skill-kit/test';

const result = await runComposite(skill, {
  params: { query: 'help' },
  refs, // optional ReferenceLoader for topic content
  model: mockModel({
    choose: { choice: 'doctor' },
    'get-space': { spaceId: 'abc' },
    'doctor/diagnose': { issues: [], healthy: true },
    'doctor/report-clean': { summary: 'All good!' },
  }),
});

assert.equal(result.redirectedTo?.name, 'doctor');
OptionTypeRequiredDescription
modelModelAdapteryesProvides responses for dispatcher and sub-skill steps
paramsobjectnoDispatcher params
refsReferenceLoadernoFor topic content resolution (defaults to no-op)
hostHandshakenoHost identity. Defaults to generic
directSubskillstringnoSkip dispatcher, start a sub-skill directly

The return value adds redirectedTo?: { kind: 'subskill' | 'topic', name: string } alongside the standard path, outputs, response, and history fields.


Primitives

Interactive building blocks rendered as XML tags. Authors describe intent; the preamble maps each tag to the host’s tools.

There are two ways to attach a primitive to a step:

  1. Directly in the prompt field (declarative) — pass act.askUser(...) (or any primitive) straight to prompt for steps that consist entirely of one primitive with no additional prose.
  2. act.* in a prompt function (composable) — call act.askUser(...) etc. inside the prompt callback to control exactly where the primitive XML tag appears, mix primitives with system segments, or derive primitive config from store/params.

Both approaches produce identical XML output. Pass a primitive directly to prompt for static configs; use act.* in a prompt function when you need composition or dynamic values.

act.askUser — structured or open

import { act } from '@contentful/skill-kit';

// Single-primitive step — passed directly to prompt
.step('choose-env', {
  prompt: act.askUser({
    type: 'structured',
    question: 'Which environment?',
    options: [
      { value: 'production', label: 'Production', description: 'Live traffic' },
      { value: 'staging', label: 'Staging' },
    ],
    multiSelect: false, // optional, defaults to false
  }),
  response: type({ env: "'production' | 'staging'" }),
  next: 'deploy',
})

// Composed with other prompt pieces via act (from PromptContext)
.step('ask-stack', {
  prompt: ({ act }) => [
    act.askUser({ type: 'open', question: "What's your tech stack?" }),
    `Get specific — frameworks, build tools, deployment targets.`,
  ],
  response: type({ answer: 'string' }),
  next: 'done',
})

act.confirm — binary approval

import { act } from '@contentful/skill-kit';

// Single-primitive step — passed directly to prompt
prompt: act.confirm({
  message: 'This will delete 47 files in .cache/. Continue?',
  destructive: true, // optional — adds warning in prose
  defaultAnswer: 'no', // optional — 'yes' or 'no'
}),

// Or via act in a prompt function
prompt: ({ act }) => [
  act.confirm({ message: 'Delete .cache/?', destructive: true }),
  `Explain what will happen before confirming.`,
],

Step response should include { approved: boolean }.

act.plan — present and approve

import { act } from '@contentful/skill-kit';

// Single-primitive step — passed directly to prompt
prompt: act.plan({
  summary: 'Migrate database schema',
  steps: ['Backup current schema', 'Run migration', 'Validate'],
}),

// Or via act in a prompt function
prompt: ({ act }) => act.plan({ summary: '...', steps: [...] }),

act.checklist — tracked task list

import { act } from '@contentful/skill-kit';

// Single-primitive step — passed directly to prompt
prompt: act.checklist({
  create: [
    { title: 'Lint config', status: 'pending' },
    { title: 'Test suite', status: 'pending' },
  ],
}),

// Or via act in a prompt function
prompt: ({ store, act }) => [
  act.checklist({ create: store.steps.all('task').map(t => ({ title: t.name, status: 'pending' })) }),
  `Work through each task. Update the checklist as you go.`,
],

act.subagent — spawn isolated sub-agent

import { act } from '@contentful/skill-kit';

// Single-primitive step — passed directly to prompt
prompt: act.subagent({
  prompt: 'Review the PR for security issues.',
  output: type({ findings: 'string[]' }),
}),

// Or via act in a prompt function
prompt: ({ act }) => act.subagent({ prompt: 'Review the PR.', output: FindingsSchema }),

SubagentConfig

FieldTypeRequiredDescription
promptstringyesTask description for the sub-agent
outputtype.AnyyesSchema the sub-agent’s result must satisfy
allowRecursionbooleannoDefault false. When false, the rendered XML includes a no-recurse attribute set to the skill name, preventing the subagent from re-invoking the same skill. When true, no guard is emitted.

Default (recursion blocked):

<subagent no-recurse="my-skill">Review the PR for security issues.</subagent>

With allowRecursion: true:

<subagent>Run the doctor sub-skill.</subagent>

The preamble instruction for <subagent> tells the model: if no-recurse is set, the subagent must not invoke the skill named in the attribute.

Composable Prompt Vocabulary

When a prompt function needs to mix primitives with other content or derive primitive config dynamically, use the act and system builders available on PromptContext:

.step('build', {
  prompt: ({ store, act, system }) => [
    system`You are a methodical build mentor. Complete each item before moving on.`,

    act.checklist({
      create: [
        { title: 'Board data structure', status: 'pending' },
        { title: `${store.steps['choose-renderer'].renderer} renderer`, status: 'pending' },
      ],
    }),

    `Build the game using ${store['choose-renderer'].renderer} rendering.`,
  ],
  response: type({ filesCreated: 'string[]' }),
  next: 'review',
})

The prompt function returns PromptReturn — a string, a single PromptPiece, or an array of PromptPiece values. Each piece is one of:

The engine assembles pieces in author order: resolvePromptValue evaluates the prompt config, normalizePieces wraps the result in an array, then assemblePieces wraps each piece in its XML tag (<prompt> for strings, <system> for system segments, primitive-specific tags like <ask-user> or <checklist> for act segments) and joins with double newlines.

XML output format

All prompt segments are emitted as XML tags in the output: <system>, <prompt>, <ask-user>, <confirm>, <plan>, <checklist>, <survey>, <subagent>, <rendered>. No tool names appear in the XML. The <subagent> tag may include a no-recurse attribute naming the skill that the subagent must not invoke (see allowRecursion above). The preamble (sent once at session start) maps tags to host-specific tools.

The preamble is a markdown table with columns: Tag, Tool, How to use. Each primitive’s preambleRow() method generates its row, with the tool column populated by resolveTools() which uses three-way resolution: no explicit tools → host registry; explicit tools + --subagent → authoritative (no registry merge); explicit tools without --subagent → unioned with host registry.

Example preamble table (Claude Code):

TagToolHow to use
<system>Behavioral directives. Follow as persona/tone guidelines.
<prompt>Task instructions. The work to perform.
<ask-user>AskUserQuestionPresent <option> children as choices via the tool. For type=“open”, ask conversationally. Return selected value(s) verbatim.
<confirm>AskUserQuestionYes/no via the tool. Respect default attribute. If destructive, emphasize consequences.
<plan>EnterPlanModePresent summary + <step> children via the tool. Wait for approval.
<checklist>TaskCreateRegister <item> children via the tool. Update status as each completes.
<survey>AskUserQuestionPresent <question> children as a batched questionnaire via the tool. Return all answers.
<subagent>AgentSpawn isolated agent for enclosed task via the tool. If no-recurse is set, the subagent must not invoke the named skill. Return its output.
<rendered>Pre-rendered output from the skill. Emit verbatim — no edits, no commentary.

When no matching tool is available, the tool column is and the instruction falls back to prose-only behavior (e.g., numbered lists for <plan>, markdown checklists for <checklist>).

Same skill, every host. The preamble handles the translation.

Primitive type contract

Each primitive is defined with definePrimitive():

import { definePrimitive } from './primitive.js';

interface RenderContext {
  skillName?: string;
}

definePrimitive({
  tag: string,                                    // XML tag name (e.g., 'ask-user')
  tools: readonly string[],                       // tool names to match against host inventory
  create: (input: TInput) => TConfig,             // normalize author input to frozen config
  render: (config: TConfig, ctx?: RenderContext) => string,  // produce XML output
  preambleRow: (tool: string | undefined) => PreambleRow,    // generate preamble table row
});

The render method accepts an optional RenderContext. The engine passes { skillName } when calling renderPrimitive, allowing primitives like subagent to emit the skill name in the no-recurse attribute.

The registry (src/primitives/registry.ts) exposes two functions:


Standalone Steps

For shared, reusable steps defined outside a skill:

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

const openQuestion = step({
  response: type({ answer: 'string' }),
  next: '__parent__',
});

Both response and next are required. Use via .extend() on the builder to get typed overrides that respect the parent skill’s params/store types.


Fragments and Prompts

fragment() — named prose snippet

import { fragment } from '@contentful/skill-kit';

const playfulTone = fragment(
  'playful-tone',
  `Keep it light and fun. Use casual language.
   Throw in a joke if it fits.`,
);

Creates an immutable { name, content } object. Content is trimmed on creation.

prompt — tagged template literal

import { prompt } from '@contentful/skill-kit';

const myPrompt = prompt`
  ${playfulTone}

  Now ask the user about their hobbies.
  Be specific — "sports" is boring, "underwater basket weaving" is a personality.
`;

The prompt tag:

  1. Detects Fragment objects in interpolation slots (duck-typed: { name, content }) and inserts their content
  2. Converts non-Fragment values to strings
  3. Auto-dedents the result: strips leading/trailing empty lines, then removes the minimum shared indentation from all lines

This means you can indent prompt blocks naturally in your code without the indentation leaking into the output.


View Helper

The view() helper wraps pre-rendered content in a ViewSegment that renders as a <rendered> XML tag. Views are composed inline within prompt functions — use them to inject deterministic output (tables, reports, cards) that the model presents verbatim.

import { view } from '@contentful/skill-kit';

view(content) — unnamed

view(render.table(checks, { columns: ['name', 'status'] }));
// → <rendered>\n...\n</rendered>

view(label, content) — named

view('report', render.table(checks, { columns: ['name', 'status'] }));
// → <rendered name="report">\n...\n</rendered>

Named views help the model reference specific rendered blocks when a prompt contains multiple views. Use inside prompt callbacks:

.step('report', {
  prompt: ({ store }) => {
    const checks = store.steps.diagnose.checks;
    return [
      view('report', render.table(checks, { columns: ['name', 'status', 'detail'] })),
      `Output the report above to the user exactly as shown.`,
    ];
  },
  response: type({ delivered: 'boolean' }),
  next: terminal,
})

terminal Constant

import { terminal } from '@contentful/skill-kit';

// Equivalent to { terminal: true }
step({ next: terminal });

A convenience constant for terminal transitions. Use terminal instead of { terminal: true } for cleaner code.


Actions

Side effects that run after a step’s response is validated:

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

const writeProfile = action({
  name: 'write-profile',
  input: type({ profile: ProfileSchema }),
  output: type({ path: 'string' }),
  run: async ({ input, signal }) => {
    const path = `/tmp/profile-${Date.now()}.json`;
    await writeFile(path, JSON.stringify(input.profile));
    return { path };
  },
});

Attach to a step via the action field:

.step('save', {
  prompt: 'Generate the profile.',
  response: type({ profile: ProfileSchema }),
  action: { run: writeProfile },
  next: { terminal: true },
})

Decoupling action input from step response

When the model’s response doesn’t match action input exactly:

.step('save', {
  response: type({ reasoning: 'string', profile: ProfileSchema }),
  action: {
    run: writeProfile,
    mapInput: ({ response }) => ({ profile: response.profile }),
  },
  save: ({ response, actionResult }) => ({
    step: { profile: response.profile, savedPath: actionResult.path },
  }),
  next: 'confirm',
})

action() config

FieldTypeRequiredDescription
namestringyesAction identifier
inputtype.AnyyesSchema for what the action receives
outputtype.AnyyesSchema for what the action returns
run(ctx: { input, signal: AbortSignal }) => Promise<output>yesAsync function with typed I/O

The run function receives input parsed through the input schema and an AbortSignal. By default, the step’s validated response is parsed as action input; use action.mapInput on the step config to customize.


Render Helpers

Formatting utilities for generating markdown output in step prompts and render functions:

import { render } from '@contentful/skill-kit';

render.table(rows, opts?)

render.table(
  [
    { name: 'ci', status: 'fail', detail: 'no config' },
    { name: 'lint', status: 'pass', detail: 'eslint configured' },
  ],
  { columns: ['name', 'status', 'detail'] },
);

Options: columns?: string[] (column order, defaults to first row’s keys), statusIcons?: Record<string, string> (custom icons for status column values). Returns empty string for empty rows.

render.checklist(items)

render.checklist([
  { text: 'TypeScript', done: true },
  { text: 'Bun', done: true },
  { text: 'Deploy script', done: false },
]);
// - [x] TypeScript
// - [x] Bun
// - [ ] Deploy script

render.code(source, lang?)

render.code('const x = 42;', 'typescript');

Wraps in triple-backtick fence with optional language tag.

render.kv(pairs)

render.kv({ Name: 'Alice', Role: 'Developer', Stack: 'TypeScript + Bun' });
// Name   Alice
// Role   Developer
// Stack  TypeScript + Bun

Keys are padded to the longest key length. Returns empty string for empty input.

render.section(title, body)

render.section('Health Checks', render.table(checks));
// ## Health Checks
//
// | name | status | ...

render.diff(before, after)

Line-by-line diff with --- before / +++ after headers. Unchanged lines prefixed with space, removed with -, added with +.


Observers

Lifecycle hooks for telemetry, logging, or analytics:

skill({
  // ...
  observers: {
    onStepStart: ({ step, params }) => {
      console.error(`→ entering "${step}"`);
    },
    onStepComplete: ({ step, response, durationMs }) => {
      console.error(`done "${step}" completed in ${durationMs}ms`);
    },
    onStepValidationFailed: ({ step, raw, error, attempt }) => {
      console.error(`fail "${step}" attempt ${attempt}: ${error}\n  raw: ${JSON.stringify(raw)}`);
    },
    onTransition: ({ from, to, reason }) => {
      console.error(`  ${from} -> ${to} (${reason})`);
    },
    onSkillComplete: ({ path, finalOutput, durationMs }) => {
      console.error(`done in ${durationMs}ms, path: ${path.join(' -> ')}`);
    },
  },
});

Observers are fire-and-forget — they don’t affect workflow execution. All are optional. They write to stderr by convention so they don’t interfere with the JSON protocol on stdout.


Testing

import { runSkill, mockModel } from '@contentful/skill-kit/test';

runSkill(skill, opts)

Drives a skill to completion with a model adapter:

const result = await runSkill(mySkill, {
  params: { repoPath: '.' }, // optional — parsed against skill's params schema
  model: mockModel({
    /* ... */
  }),
  host: { host: 'claude-code' }, // optional — defaults to generic
});

result.path; // string[] — sequence of step names visited
result.outputs; // Record<string, unknown> — raw model responses by step
result.response; // unknown — final output
result.history; // readonly StepResult[] — validated outputs + action results
result.store; // StoreAccessor — typed store accessor

mockModel(map)

Maps step names to canned responses:

mockModel({
  diagnose: { checks: [{ name: 'ci', status: 'fail' }] }, // static value
  remediate: [
    // array — cycles through on repeated visits
    { action: 'add CI' },
    { action: 'fix lint' },
  ],
  report: (prompt) => ({
    // function
    summary: prompt.includes('fail') ? 'issues found' : 'clean',
  }),
});

CLI Commands

skill-kit build

Compiles a skill into a distributable agentskills.io-compliant directory:

skill-kit build <entry.ts> -o <dir>                        # default (bun mode, session protocol)
skill-kit build <entry.ts> -o <dir> --mode node             # Node.js bundle
skill-kit build <entry.ts> -o <dir> --protocol stateless    # stateless invocation instructions
skill-kit build <entry.ts> -o <dir> --targets darwin-arm64,linux-x64,linux-arm64
skill-kit build <entry.ts> -o <dir> --single                # current platform only (fast dev builds)
FlagRequiredDescription
-o, --outyesOutput directory
--modenobun (default, platform-specific executables) or node (single .mjs bundle)
--protocolnosession (default) or stateless. Controls SKILL.md invocation instructions
--targetsnoComma-separated platforms. Defaults to darwin-arm64,linux-x64. Bun mode only.
--singlenoBuild only for current platform. Bun mode only.

Output (bun mode):

<dir>/
  SKILL.md               ← Generated agent-facing docs
  package.json           ← Name, version, and package config fields
  scripts/run            ← Shell wrapper (platform dispatcher)
  bin/<name>-<platform>  ← Compiled Bun executables
  references/            ← Copied from source

Output (node mode):

<dir>/
  SKILL.md               ← Generated agent-facing docs
  package.json           ← Name, version, and package config fields
  scripts/run            ← Shell wrapper (Node version check)
  bin/<name>.mjs         ← Single ESM bundle
  references/            ← Copied from source

MCP server mode

Every built skill binary supports MCP as an alternative to the CLI protocol. Start the MCP server with:

scripts/run mcp --host claude-code
FlagDescription
--hostHost identifier for tool resolution. Same values as CLI mode
--toolsComma-separated list of available tools (merged with host registry)

The server registers two MCP tools:

ToolInputDescription
start{ params?: object }Begin a new workflow session
advance{ session: string, step: string, output: object }Submit step output, get next prompt

For composite skills, a topic tool is also registered when topics exist.

Configure in your MCP client (e.g., Claude Code settings.json):

{
  "mcpServers": {
    "my-skill": {
      "command": "/path/to/skill/scripts/run",
      "args": ["mcp", "--host", "claude-code"]
    }
  }
}

skill-kit run

Dev mode — run a skill without compiling:

# Session mode (recommended)
skill-kit run <entry.ts> start --params '{}' --host claude-code --session new
skill-kit run <entry.ts> advance --session <id>

# Stateless mode
skill-kit run <entry.ts> start --params '{}' --host claude-code
skill-kit run <entry.ts> advance --step greet --output '{"name":"Alice"}' --params '{}' --history '[]' --host claude-code

skill-kit check

Lint a skill for portability and correctness issues:

skill-kit check <entry.ts>

Rules:

RuleSeverityWhat it catches
cycle-guardwarning/errorWarning when cycles lack maxVisits (implicit limit applies at runtime); error when cycle-guard config is invalid (e.g., onMaxVisits targets a non-existent step)
no-host-tool-nameserrorDirect host tool name references without host.toolsAvailable guard
primitive-schema-mismatcherroraskUser option values missing from output enum (or vice versa)
orphan-referenceswarningFiles in references/ not mentioned in any step prompt
unknown-tool-nameswarninghost.toolsAvailable.includes() checks referencing unrecognized tools
host-branching-densitywarningMultiple steps branching on host.toolsAvailable (suggests missing primitive)
composite-step-nameerrorDispatcher step name contains / (reserved for sub-skill namespacing)
composite-duplicate-subskillerrorDuplicate sub-skill name
composite-duplicate-topicerrorDuplicate topic name

For composite skills, checkSkill also recursively lints each registered sub-skill. Sub-skill diagnostics are prefixed with [subskill:<name>].


Linting (checkSkill)

The checkSkill function validates a skill definition programmatically. It is the same check skill-kit check runs under the hood.

import { checkSkill } from '@contentful/skill-kit';
import type { LintDiagnostic } from '@contentful/skill-kit';

const diagnostics: LintDiagnostic[] = checkSkill(skill.build(), '.');

for (const d of diagnostics) {
  console.error(`[${d.severity}] ${d.rule}: ${d.message}`);
}

Parameters:

ParameterTypeDescription
skillSkillDefinitionA built skill definition (the return value of .build())
rootDirstringRoot directory of the skill project (for orphan-references rule)

Returns: LintDiagnostic[] — an array of diagnostics, each with:


Worked Example: deploy-check

A complete skill showing params, the store, askUser, declarative branching, and terminal steps:

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

export default skill({
  name: 'deploy-check',
  entry: 'choose',
  params: type({ 'env?': "'staging'" }),
})
  .step('choose', {
    prompt: act.askUser({
      type: 'structured',
      question: 'Which environment?',
      options: [
        { value: 'production', label: 'Production' },
        { value: 'staging', label: 'Staging' },
      ],
    }),
    response: type({ target: "'production' | 'staging'" }),
    next: 'verify',
  })
  .step('verify', {
    prompt: ({ store }) => `Run pre-deploy checks for ${store.steps.choose.target}. Report any blockers.`,
    response: type({ blockers: 'string[]', safe: 'boolean' }),
    next: [{ to: 'deploy', when: ({ response }) => response.safe }, { to: 'abort' }],
  })
  .step('deploy', {
    prompt: 'Execute the deployment.',
    response: type({ url: 'string' }),
    next: terminal,
  })
  .step('abort', {
    prompt: 'Report the blockers and explain why deployment was aborted.',
    response: type({ summary: 'string' }),
    next: terminal,
  })
  .build();

What’s happening:

  1. choose — Uses prompt: act.askUser(...) to present environment options. The agent sees the options via host-appropriate tooling (AskUserQuestion on Claude Code, prose list elsewhere).

  2. verify — Dynamic prompt reads store.steps.choose.target to customize the instruction. Response schema enforces a safe boolean. Declarative branching routes to deploy or abort.

  3. deploy / abort — Two terminal paths. The skill ends cleanly regardless of which path is taken.

Testing it:

import { runSkill, mockModel } from '@contentful/skill-kit/test';
import deploy from './skill.ts';

const result = await runSkill(deploy, {
  model: mockModel({
    choose: { target: 'staging' },
    verify: { blockers: [], safe: true },
    deploy: { url: 'https://staging.example.com' },
  }),
});

// result.path -> ['choose', 'verify', 'deploy']
// result.response -> { url: 'https://staging.example.com' }

Params and store types flow end-to-end — store.steps.choose.target in the verify prompt is typed as string, and the choose step’s response schema is checked against the ArkType definition. No type annotations needed anywhere.