Example: contentful-help

A composite skill that dispatches to doctor and setup sub-skills, or resolves FAQ topics directly. This is the composite example in the SDK, demonstrating sub-skill registration, topic routing, actions, and multi-skill testing.

Source: examples/contentful-help/src/skill.ts


Skill structure

examples/contentful-help/src/
  skill.ts                   # Dispatcher -- routes to sub-skills or topics
  skill.test.ts              # Tests with runComposite
  subskills/
    doctor.ts                # Diagnose and fix issues (standalone skill)
    setup.ts                 # Guided space configuration (standalone skill)
  references/
    rate-limits.md           # API rate limit reference
    locales.md               # Locale configuration reference

The dispatcher (skill.ts) is a regular skill() with .subskill() and .topic() calls. Each sub-skill is a standalone skill().build() in its own file.

The dispatcher

The entry step uses act.askUser to let the user pick their intent:

.step('choose', {
  prompt: act.askUser({
    type: 'structured',
    question: 'What would you like help with?',
    options: [
      { value: 'doctor', label: 'Diagnose issues', description: 'Find and fix problems' },
      { value: 'setup', label: 'Set up Contentful', description: 'Configure your space' },
      { value: 'faq', label: 'Quick question', description: 'Look up reference information' },
    ],
  }),
  response: type({ choice: "'doctor' | 'setup' | 'faq'" }),
  next: ({ response }) => {
    if (response.choice === 'faq') return 'ask-topic';
    if (response.choice === 'doctor') return 'get-space';
    return `subskill:${response.choice}`;
  },
})

The dispatcher can have multiple steps before routing — get-space gathers the space ID before handing off to the doctor sub-skill, and ask-topic asks which topic to look up before resolving it.

Sub-skill registration

Sub-skills are imported and registered with an optional params mapping:

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

.subskill('doctor', doctorSkill, {
  params: (_response, store) => ({ spaceId: store['get-space']?.spaceId ?? '' }),
})
.subskill('setup', setupSkill)

The params function receives the dispatching step’s response and the dispatcher’s store. Its return value becomes the sub-skill’s params.

Topic registration

Topics are reference entries that can be resolved directly from the dispatcher via topic:<name>:

.topic('rate-limits', {
  label: 'API rate limits and throttling',
  content: ({ refs }) => refs.load('rate-limits.md'),
})

When next returns 'topic:rate-limits', the skill loads the topic content and returns it as a DoneResult — no sub-skill workflow needed.

The doctor sub-skill

A multi-step workflow: diagnose -> suggest fixes -> confirm -> apply -> report. Shows conditional branching (healthy spaces skip remediation) and askUser for fix confirmation.

The setup sub-skill

Uses an action for deterministic environment variable detection — no LLM guessing. The checkEnv action reads process.env directly:

const checkEnv = action({
  name: 'check-env',
  input: type({}),
  output: type({ hasSpaceId: 'boolean', hasToken: 'boolean' }),
  run: async () => ({
    hasSpaceId: !!process.env['CONTENTFUL_SPACE_ID'],
    hasToken: !!process.env['CONTENTFUL_ACCESS_TOKEN'],
  }),
});

The step’s result callback shapes what goes into the store, and next routes based on the action output — either to guided setup or directly to configuration.

Testing

Tests use runComposite which handles dispatcher->sub-skill routing automatically:

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

test('choose doctor routes through get-space to doctor sub-skill', async () => {
  const result = await runComposite(skill, {
    model: mockModel({
      choose: { choice: 'doctor' },
      'get-space': { spaceId: 'abc123' },
      'doctor/diagnose': { issues: ['broken refs'], healthy: false },
      'doctor/suggest-fix': { fixes: [{ issue: 'broken refs', fix: 'republish' }] },
      'doctor/confirm-fix': { choice: 'skip' },
      'doctor/report-issues': { summary: 'Found 1 issue.' },
    }),
  });

  assert.equal(result.redirectedTo?.name, 'doctor');
});

Sub-skill steps use prefixed names in the mock ('doctor/diagnose'). Topic tests provide a refs option pointing to the actual reference files:

test('choose faq routes through ask-topic to topic content', async () => {
  const result = await runComposite(skill, {
    refs,
    model: mockModel({
      choose: { choice: 'faq' },
      'ask-topic': { topicName: 'rate-limits' },
    }),
  });

  assert.equal(result.redirectedTo?.name, 'rate-limits');
  assert.ok(result.output.content.includes('78 requests/second'));
});

Use directSubskill to test a sub-skill in isolation, bypassing the dispatcher entirely.