Example: get-to-know-you

A playful interview skill that gets to know the user through a series of questions and produces a developer trading card. This is the most comprehensive example in the SDK, demonstrating nearly every workflow feature.

Source: examples/get-to-know-you/src/skill.ts


What it does

The skill runs through a multi-step interview:

  1. Greet the user and ask their name
  2. Ask their role (developer, designer, manager, or other) using structured selection
  3. Branch to a role-specific follow-up question (tech stack, design tools, team size, or specialty)
  4. Collect hobbies in a loop (the user can add multiple)
  5. Confirm the profile before generating
  6. Render a trading card with all collected data and write the profile as an action

Key patterns

System-level persona

The skill declares a system string that sets the tone for the entire workflow:

system:
  "Keep it light and fun. Use casual language. Throw in the occasional joke or pun if it fits. You're a friendly interviewer, not a form.",

Params

The skill declares immutable params (a greeting string with a default):

params: type({
  greeting: 'string = "Hey there!"',
}),

Params are typed everywhere — params.greeting in the first step’s prompt is typed as string without annotations.

Structured askUser with declarative branching

The ask-role step uses act.askUser with structured options. The SDK generates host-appropriate XML (mapped to AskUserQuestion on Claude Code, a prose option list elsewhere). Declarative branching routes based on the role:

.step('ask-role', {
  prompt: act.askUser({
    type: 'structured',
    question: "What's your primary role?",
    options: [
      { value: 'dev', label: 'Developer', description: 'I write code for a living' },
      { value: 'designer', label: 'Designer', description: 'I make things pretty and usable' },
      { value: 'manager', label: 'Manager', description: 'I herd cats professionally' },
      { value: 'other', label: 'Something else', description: 'I defy your categories' },
    ],
  }),
  response: type({ role: "'dev' | 'designer' | 'manager' | 'other'" }),
  next: [
    { to: 'ask-stack', when: ({ response }) => response.role === 'dev' },
    { to: 'ask-tools', when: ({ response }) => response.role === 'designer' },
    { to: 'ask-team-size', when: ({ response }) => response.role === 'manager' },
    { to: 'ask-specialty' },
  ],
})

Because the SDK can read the branch targets at compile time, it knows that store['ask-stack'] is optional (only developers take that path) while store['ask-role'] is guaranteed (every path goes through it).

The store in action

The ask-stack step reads from the store with full type safety. store.greet.name is non-optional because greet is guaranteed to have run:

.step('ask-stack', {
  prompt: ({ store }) => {
    const name = store.greet.name;  // guaranteed -- no ?. needed
    return [
      prompt`
        ${name} is a developer -- nice!
        Ask what their go-to tech stack is.
      `,
      act.askUser({ type: 'open', question: "What's your go-to tech stack?" }),
    ];
  },
  response: type({ answer: 'string' }),
  next: 'ask-hobby',
})

Loop guards on hobby collection

The ask-hobby step can loop back to itself when the user wants to add more hobbies, but it has a safety valve:

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

After 2 visits, the skill automatically redirects to confirm-profile instead of looping again. The backward edge (ask-hobby -> ask-hobby) doesn’t create a false branch — the forward path to confirm-profile is still guaranteed.

Confirm with bounce-back

The confirm-profile step uses the act.confirm primitive. If the user declines, they bounce back to add another hobby:

.step('confirm-profile', {
  prompt: act.confirm({
    message: 'Got enough for a great trading card! Ready to see it, or want to add one more hobby?',
    defaultAnswer: 'yes',
  }),
  response: type({ approved: 'boolean' }),
  next: [
    { to: 'profile-card', when: ({ response }) => response.approved },
    { to: 'ask-hobby' },
  ],
  maxVisits: 3,
  onMaxVisits: 'profile-card',
})

Graph-aware store in the final step

The terminal profile-card step demonstrates the full power of the store’s graph awareness:

prompt: ({ store, refs }) => {
  const name = store.greet.name;              // guaranteed -- non-optional
  const role = store['ask-role'].role;         // guaranteed -- non-optional

  const specialty =
    store['ask-stack']?.answer ??              // branch target -- optional, use ?.
    store['ask-tools']?.answer ??              // branch target -- optional
    store['ask-team-size']?.answer ??          // branch target -- optional
    store['ask-specialty']?.answer ??          // branch target -- optional
    'Classified';

  const hobbies = store.all('ask-hobby').map((v) => v.hobby);  // loop visits

  // ... render the card with render.kv, render.checklist, render.section
},

store.greet.name and store['ask-role'].role are non-optional because every workflow path goes through those steps. The branch-specific steps (ask-stack, ask-tools, etc.) require ?. because only one of them runs. store.all('ask-hobby') returns a typed array of all loop visits. TypeScript enforces all of this at compile time.

Actions for side effects

The skill defines a writeProfile action that writes the profile to disk. It is attached to the terminal step:

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

// Attached via:
.step('profile-card', {
  // ...
  action: { run: writeProfile },
  next: { terminal: true },
})

Observers

The skill registers a transition observer that logs step transitions to stderr — useful for debugging during development:

observers: {
  onTransition: ({ from, to }) => {
    process.stderr.write(`[get-to-know-you] ${from} -> ${to}\n`);
  },
},

Testing

The test file (skill.test.ts) uses runSkill and mockModel to drive the skill through different paths without any real model calls.

Developer path (happy path):

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

const result = await runSkill(skill, {
  params: { greeting: 'Howdy!' },
  model: mockModel({
    greet: { name: 'Alice' },
    'ask-role': { role: 'dev' },
    'ask-stack': { answer: 'TypeScript + Bun + Zod' },
    'ask-hobby': { hobby: 'Rock climbing', wantsMore: false },
    'confirm-profile': { approved: true },
    'profile-card': {
      card: 'rendered card',
      profile: {
        name: 'Alice',
        role: 'dev',
        specialty: 'TypeScript + Bun + Zod',
        hobbies: ['Rock climbing'],
        funFact: 'A group of developers is called a "merge conflict."',
      },
    },
  }),
});

assert.deepEqual(result.path, ['greet', 'ask-role', 'ask-stack', 'ask-hobby', 'confirm-profile', 'profile-card']);

Testing the hobby loop with array-based mock responses that cycle through on repeated visits:

model: mockModel({
  // ...
  'ask-hobby': [
    { hobby: 'Baking', wantsMore: true },   // first visit
    { hobby: 'Chess', wantsMore: false },    // second visit
  ],
  // ...
}),

// result.path includes 'ask-hobby' twice

Testing the confirm bounce-back — the user declines confirmation, adds another hobby, then confirms:

model: mockModel({
  // ...
  'ask-hobby': [
    { hobby: 'Gaming', wantsMore: false },
    { hobby: 'Cooking', wantsMore: false },
  ],
  'confirm-profile': [
    { approved: false },   // first visit -- bounce back
    { approved: true },    // second visit -- proceed
  ],
  // ...
}),

The test suite covers all four role branches (developer, designer, manager, other), the hobby loop, and the confirm bounce-back — verifying paths and ensuring the workflow terminates correctly in every case.