Primitives
Primitives are host-aware interactive building blocks rendered as XML tags. They let you express intent — “ask the user a question,” “present a plan for approval” — and the SDK produces XML calibrated for whichever agent host is running the skill. On Claude Code, the preamble maps <ask-user> to AskUserQuestion. On hosts without a matching tool, generic fallback instructions apply. Same skill, same XML, every host.
The CLI can only emit text. Primitives are the mechanism that makes that text reliable across hosts, and the place where prompt-engineering effort compounds across the ecosystem.
How primitives work
Primitives can be used two ways:
- Directly in the
promptfield — passact.askUser(...)(or any primitive) straight topromptfor steps that consist entirely of one primitive with no additional prose. actmethods in prompt functions — composable directives mixed with other prompt pieces.
When a step uses a primitive, the SDK renders it as an XML tag (e.g., <ask-user>, <plan>, <checklist>). The preamble (sent once at session start) maps each tag to the host’s tools via a markdown table. The response schema is the contract regardless of host — downstream steps never know how the answer was obtained.
act.askUser() — structured and open questions
A single primitive with two modes, discriminated by type.
Structured mode
Presents fixed options via the host’s structured question tool (if available) or via a prose option list.
import { act, type } from '@contentful/skill-kit';
// Single-primitive step — passed directly to prompt
.step('choose-target', {
prompt: act.askUser({
type: 'structured',
question: 'Which deployment target?',
options: [
{ value: 'production', label: 'Production', description: 'Live, customer-facing' },
{ value: 'staging', label: 'Staging', description: 'Pre-production mirror' },
{ value: 'local', label: 'Local', description: 'Dev environment' },
],
}),
response: type({ target: "'production' | 'staging' | 'local'" }),
next: ({ response }) => `deploy-${response.target}`,
})
// Or composed with other prompt pieces
.step('choose-target', {
prompt: ({ act }) => [
act.askUser({
type: 'structured',
question: 'Which deployment target?',
options: [
{ value: 'production', label: 'Production', description: 'Live, customer-facing' },
{ value: 'staging', label: 'Staging' },
{ value: 'local', label: 'Local' },
],
}),
`Explain what each environment is used for before asking.`,
],
response: type({ target: "'production' | 'staging' | 'local'" }),
next: ({ response }) => `deploy-${response.target}`,
})
| Field | Type | Required | Description |
|---|---|---|---|
type | 'structured' | Yes | Discriminant for structured mode. |
question | string | Yes | The question text shown to the user. |
options | AskUserOption[] | Yes | Array of { value, label, description?, preview?, header? }. |
multiSelect | boolean | No | Allow multiple selections. Defaults to single-select. |
Options support optional preview and header fields on the structured config for richer presentation.
Open mode
Free-text conversation. Never uses a structured question tool — the agent asks conversationally and the user answers in their own words.
.step('ask-stack', {
prompt: act.askUser({ type: 'open', question: "What's your go-to tech stack?" }),
response: type({ answer: 'string' }),
next: 'done',
})
| Field | Type | Required | Description |
|---|---|---|---|
type | 'open' | Yes | Discriminant for open mode. |
question | string | Yes | The question text. |
act.confirm() — binary approval
Binary yes/no confirmation with support for destructive-operation warnings and default answers.
import { act, type } from '@contentful/skill-kit';
.step('confirm-delete', {
prompt: act.confirm({
message: 'This will delete 47 files in .cache/. Continue?',
destructive: true,
defaultAnswer: 'no',
}),
response: type({ approved: 'boolean' }),
next: ({ response }) => response.approved ? 'proceed' : 'abort',
})
| Field | Type | Required | Description |
|---|---|---|---|
message | string | Yes | The confirmation message shown to the user. |
destructive | boolean | No | Flags the operation as destructive. Adds warning emphasis in generated prose. |
defaultAnswer | 'yes' | 'no' | No | Default answer on ambiguity. Defaults to 'no' if not specified. |
The confirm primitive is distinct from askUser because destructive-op confirmation needs stronger defaults and warning framing that differs from a neutral question.
act.plan() — present a plan for approval
Shows a structured plan (summary + ordered steps) and asks the user whether to proceed.
import { act, type } from '@contentful/skill-kit';
.step('review-plan', {
prompt: act.plan({
summary: 'Migrate auth from session cookies to JWTs',
steps: [
'Add JWT signing and verification helpers',
'Update login flow to issue JWTs',
'Add compatibility layer for existing sessions',
'Update middleware to accept both',
'Migration script for active sessions',
],
}),
response: type({ approved: 'boolean', 'modifications?': 'string' }),
next: ({ response }) => response.approved ? 'execute' : 'revise',
})
| Field | Type | Required | Description |
|---|---|---|---|
summary | string | Yes | High-level description of what the plan accomplishes. |
steps | string[] | Yes | Ordered list of plan steps. |
On Claude Code, this maps to EnterPlanMode/ExitPlanMode for a first-class plan UI. On other hosts, it renders as a numbered list with an explicit approval prompt.
act.checklist() — tracked task list
Creates a tracked list of tasks the agent should work through.
import { act, type } from '@contentful/skill-kit';
.step('create-tasks', {
prompt: act.checklist({
create: [
{ title: 'Fix CI configuration', status: 'pending' },
{ title: 'Update dependencies', status: 'pending' },
{ title: 'Run integration tests', status: 'pending' },
],
}),
response: type({ acknowledged: 'boolean' }),
next: 'execute-tasks',
})
| Field | Type | Required | Description |
|---|---|---|---|
create | Array<{ title: string; status: string }> | Yes | Tasks to register, each with a title and initial status. |
act.subagent() — spawn an isolated sub-agent
Delegates a focused piece of work to a sub-agent with its own context window. The sub-agent works independently and returns structured output.
import { act, type } from '@contentful/skill-kit';
const ResearchSummary = type({
findings: {
cve: 'string',
severity: "'low' | 'medium' | 'high' | 'critical'",
affected: 'string',
}[],
});
.step('research', {
prompt: act.subagent({
prompt: 'Research the top 5 CVEs affecting our dependency tree. Return a structured summary.',
output: ResearchSummary,
}),
response: ResearchSummary,
next: 'incorporate-findings',
})
| Field | Type | Required | Description |
|---|---|---|---|
prompt | string | Yes | Instructions for the sub-agent. |
output | type.Any | Yes | ArkType schema the sub-agent’s result must match. |
allowRecursion | boolean | No | Default 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. |
On hosts without real agent isolation (no Agent tool), the prose fallback still produces correct output but doesn’t get the isolated context-window benefit.
act.survey() — batched questions
Collects answers to multiple questions in a single step, presented as a batch:
import { act, type } from '@contentful/skill-kit';
.step('gather-preferences', {
prompt: act.survey({
questions: [
{ key: 'language', question: 'Preferred programming language?' },
{ key: 'editor', question: 'Preferred editor or IDE?' },
{ key: 'os', question: 'Primary operating system?' },
],
}),
response: type({
language: 'string',
editor: 'string',
os: 'string',
}),
next: 'done',
})
XML output format and host mapping
The SDK renders all prompt segments as XML tags. The preamble (sent once at session start) maps each tag to the host’s tool via a markdown table:
| Tag | Description | Example tool (Claude Code) |
|---|---|---|
<system> | Behavioral directives (persona, tone) | --- |
<prompt> | Task instructions (plain strings get wrapped) | --- |
<ask-user> | Structured or open question | AskUserQuestion |
<confirm> | Binary yes/no confirmation | AskUserQuestion |
<plan> | Plan presentation with steps | EnterPlanMode |
<checklist> | Tracked task list | TaskCreate |
<subagent> | Sub-agent delegation (no-recurse guard) | Agent |
No tool names appear in the XML itself. The preamble table maps tags to tools. On hosts without a matching tool, the instruction column provides generic fallback behavior (e.g., present a numbered list for <ask-user>).
Same skill, same XML, every host. The preamble handles the translation.
Fragments and prompts
Two utilities for composing prose across steps and skills.
fragment() — named prose snippets
Fragments are reusable chunks of prose with a name for tracking and deduplication.
import { fragment } from '@contentful/skill-kit';
export const enterpriseTone = fragment(
'enterprise-tone',
`Use a professional, concise tone. Avoid jargon unless the user
introduced it. Prefer concrete recommendations over hedging.`,
);
export const jsonOutputRules = fragment(
'json-output',
`Return only valid JSON matching the schema. No preamble,
no markdown fences, no commentary outside the object.`,
);
| Argument | Type | Description |
|---|---|---|
name | string | Required. Identifier for tooling, tracking, and deduplication. |
content | string | The prose text. Automatically trimmed. |
The returned Fragment object is frozen with { name, content }.
prompt — tagged template literal with auto-dedent
The prompt tag handles indentation cleanup and fragment interpolation. Without it, template literals in indented TypeScript produce ugly whitespace.
import { prompt } from '@contentful/skill-kit';
import { enterpriseTone, jsonOutputRules } from '../fragments/tone.js';
.step('analyze', {
prompt: ({ store }) => prompt`
${enterpriseTone}
Analyze the dependency tree at ${store.steps.gather.repoPath}.
Flag packages with known CVEs.
${jsonOutputRules}
`,
response: AnalysisSchema,
next: 'report',
})
How it works:
- Leading and trailing empty lines are stripped
- Common indentation is removed (dedented to the leftmost non-empty line)
Fragmentobjects are interpolated as their.contentstring- Non-fragment values are converted with
String()
This is the only sugar the SDK provides for prose authoring. Everything else is standard functions.