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:
- Greet the user and ask their name
- Ask their role (developer, designer, manager, or other) using structured selection
- Branch to a role-specific follow-up question (tech stack, design tools, team size, or specialty)
- Collect hobbies in a loop (the user can add multiple)
- Confirm the profile before generating
- 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.