Workflow Skills
Workflow skills are typed state machines. Each step has a prompt, an output schema, and a transition. The agent sees one step at a time — the CLI controls the order, validates outputs, and decides what comes next.
skill() configuration
skill(config) returns a SkillBuilder. One per entry file. Params and store types declared here flow into all step callbacks via contextual type inference.
import { skill, type } from '@contentful/skill-kit';
export default skill({
name: 'deploy-check',
version: '1.0.0',
description: 'Validates deployment readiness across environments.',
entry: 'gather-info',
params: type({ env: 'string = "staging"' }),
finalOutput: type({ ready: 'boolean', report: 'string' }),
observers: {
onStepComplete: ({ step, durationMs }) => {
process.stderr.write(`[deploy-check] ${step} completed in ${durationMs}ms\n`);
},
},
skillMd: 'Custom SKILL.md template override',
})
.step('gather-info', {
/* ... */
})
.build();
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Skill identifier. Used in build output directory name and generated SKILL.md. |
entry | string | Yes | Name of the first step to execute. Must match a registered step name. |
version | string | No | Semver version string. Defaults to '0.0.0'. |
description | string | No | Human-readable description. Included in generated SKILL.md frontmatter. |
triggers | string[] | No | Trigger keywords appended to the description for agent discoverability. |
params | type.Any | No | ArkType schema for global skill params. Validated on start, typed everywhere. |
finalOutput | type.Any | No | Schema for the skill’s “return value”. Validated when the workflow terminates. |
observers | ObserverMap | No | Read-only lifecycle callbacks for logging, telemetry, and audit. |
skillMd | string | ((skill) => string) | No | Custom SKILL.md template. Overrides the generated default. |
system | string | No | System-level persona prepended to the preamble. Steps inherit it. |
argumentHint | string | No | Autocomplete hint text. Emitted as argument-hint in SKILL.md frontmatter. |
arguments | string | string[] | No | Named positional arguments for $name substitution in skill content. Emitted as arguments in frontmatter. |
allowedTools | string | string[] | No | Additional pre-approved tools. Build auto-includes CLI and MCP defaults. |
paths | string | string[] | No | Glob patterns for file-based activation. Emitted as paths in frontmatter. |
context | string | No | Execution context (e.g. 'fork'). Emitted as context in frontmatter. |
license | string | No | License name or reference. Emitted as license in frontmatter. |
compatibility | string | No | Environment requirements. Emitted as compatibility in frontmatter. |
agent | string | No | Subagent type when context: 'fork'. Emitted as agent in frontmatter. |
model | string | No | Model override while skill is active. Emitted as model in frontmatter. |
effort | string | No | Effort level override. Emitted as effort in frontmatter. |
disableModelInvocation | boolean | No | Prevent auto-loading by the agent. Emitted as disable-model-invocation. |
userInvocable | boolean | No | Whether visible in / menu. Emitted as user-invocable. |
.step(name, config)
Adds a step to the skill. The prompt callbacks receive typed params and store from the builder — no manual annotations needed.
.step('gather-info', {
prompt: ({ params }) => `Check deployment readiness for ${params.env}.`,
response: type({
checks: {
name: 'string',
passed: 'boolean',
detail: 'string',
}[],
}),
action: {
run: writeReportAction,
input: ({ response, store }) => ({ checks: response.checks }),
},
save: ({ response, actionResult }) => ({
step: { checks: response.checks, reportPath: actionResult.path },
}),
next: ({ response }) => response.checks.every(c => c.passed) ? 'approve' : 'remediate',
maxVisits: 3,
onMaxVisits: 'force-report',
})
Full step config shape
| Field | Type | Required | Description |
|---|---|---|---|
prompt | string | PromptPiece | PromptFn | No | The prose shown to the agent. Can be static, a segment, or a function. |
response | type.Any | No | ArkType schema the agent’s response must match. Omit for pass-through. |
next | string | NextBranch[] | (ctx) => string | terminal | Yes | Where to go after this step. Static name, branches, function, terminal. |
action | { run, input? } | No | CLI-side side effect that runs after response validation. |
save | (ctx: { response, actionResult, store, params }) => unknown | No | Transform what gets stored. Defaults to actionResult or response. |
maxVisits | number | No | Explicit visit limit for this step. |
onMaxVisits | string | No | Fallback step when maxVisits is exceeded. |
PromptContext
Every dynamic prompt function receives a PromptContext object. These are the fields available:
| Field | Type | Description |
|---|---|---|
store | StoreView<TSteps, TGuaranteed> | Typed accessor for prior step results (guaranteed steps non-optional, branch targets use ?.) |
params | TParams | Global skill params (config defaults + runtime overrides). |
refs | ReferenceLoader | Lazy loader for reference files: refs.load('file.md'), refs.asset('path'). |
attempts | number | How many times this step has been visited (zero-indexed, useful for retries). |
host | Handshake | Host info: { host: string, toolsAvailable: string[] }. |
act | ActBuilder | Primitive directive builders: askUser, confirm, plan, etc. |
system | SystemBuilder | System segment tag/function for persona/frame. |
The store
The store gives every step typed access to all prior step results. The SDK analyzes your workflow graph and computes which steps are guaranteed to have run by the time each step executes. This flows directly into the TypeScript types.
Guaranteed steps are non-optional — direct property access, no null checks:
store.steps.greet.name; // string -- guaranteed, TypeScript knows it ran
store.steps['ask-role'].role; // string -- guaranteed, non-optional
Branch targets are optional — the SDK enforces ?. because only one branch ran:
store.steps['ask-stack']?.answer; // string | undefined -- branch target
store.steps['ask-tools']?.answer; // string | undefined -- branch target
Loop visits — all results from a step that ran multiple times:
store.steps.all('ask-hobby'); // typed array of all visit results
store.steps.ran('ask-stack'); // boolean -- whether it ran at least once
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. There is no setState, no manual wiring, no reducers. Step results flow into the store automatically.
The save callback
By default, the store carries the step’s response (or actionResult if an action ran). The save callback transforms what gets stored — the agent contract and the step’s contribution to state are separate:
.step('save', {
response: type({ reasoning: 'string', profile: ProfileSchema }),
action: {
run: writeProfile,
input: ({ response }) => ({ profile: response.profile }),
},
save: ({ response, actionResult }) => ({
step: { profile: response.profile, savedPath: actionResult.path },
}),
next: 'confirm',
})
response is what the model gives back. The step key in the save return is what downstream steps see in store.steps['save']. The model’s reasoning stays in the response; the store carries only what matters.
Declarative branching powers type narrowing
Declarative branching with NextBranch[] lets the SDK see your transition targets at compile time. This is what powers the guaranteed vs optional distinction:
.step('ask-role', {
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, it knows that in a later step, store.steps['ask-stack'] might not exist (only developers take that path), so it requires ?.. But store.steps['ask-role'] is guaranteed — every path goes through it — so it’s non-optional.
Transitions
Every step declares a next that determines where the workflow goes after the agent responds.
Static transition
A fixed step name. The simplest case.
.step('gather', {
response: GatherSchema,
next: 'analyze',
})
Dynamic transition
A function that receives the step output and returns a step name. Use this for branching logic.
.step('diagnose', {
response: type({ severity: "'low' | 'medium' | 'high'" }),
next: ({ response }) => response.severity === 'high' ? 'escalate' : 'auto-fix',
})
The function receives { response, attempts, actionResult, params, store }.
The type system infers literal return types from the function body. In the example above, TypeScript infers the return type as "escalate" | "auto-fix", and the builder extracts both as branch targets — making them optional in the store for downstream steps. This is the same DAG-based narrowing that declarative NextBranch[] arrays use.
Declarative branching
An array of NextBranch objects. Pattern-match style — first match wins, last entry without when is the default:
.step('triage', {
response: type({ category: 'string', urgent: 'boolean' }),
next: [
{ to: 'escalate', when: ({ response }) => response.urgent },
{ to: 'queue' },
],
})
This is the preferred form for multi-way branching. It’s readable, declarative, and the builder uses the targets to compute guaranteed vs optional store entries.
Terminal transition
Ends the workflow. The last step’s output becomes the skill’s final output.
import { terminal } from '@contentful/skill-kit';
.step('report', {
response: ReportSchema,
next: terminal,
})
Self-transition
Return 'self' from a dynamic next to revisit the current step. Always pair with a loop guard.
.step('refine', {
response: type({ confidence: 'number', result: 'string' }),
next: ({ response, attempts }) =>
response.confidence < 0.8 && attempts < 3 ? 'self' : 'done',
maxVisits: 3,
onMaxVisits: 'done',
})
Loop guards
Cycles are detected by static graph analysis at build time. Any step that participates in a cycle gets an implicit visit limit of 10. For explicit control, declare maxVisits and onMaxVisits.
| Config | Behavior |
|---|---|
No maxVisits declared, step in a cycle | Implicit limit of 10 visits. Throws CycleGuardError when exceeded. |
maxVisits without onMaxVisits | Throws CycleGuardError when the limit is hit (fail-closed). |
maxVisits + onMaxVisits | Redirects to the onMaxVisits step when the limit is hit. |
Linear workflows with function-based next don’t need cycle guards — the conservative graph analysis handles safety automatically by treating dynamic transitions as potentially reaching all steps.
.step('retry-step', {
response: RetrySchema,
next: ({ response }) => response.ok ? 'done' : 'self',
maxVisits: 5,
onMaxVisits: 'fallback', // required safety valve
})
.extend(name, base, overrides)
Wires a shared step into the skill with typed overrides. The base step provides defaults; overrides replace specific fields. Params and store types from the builder flow into the override callbacks.
import { step, type } from '@contentful/skill-kit';
// Shared step — portable, no skill-specific types
const openQuestion = step({
response: type({ answer: 'string' }),
next: '__parent__',
});
// In a skill — .extend() applies typed params/store:
skill({ params: ParamsSchema, entry: 'ask', ... })
.extend('ask', openQuestion, {
prompt: ({ store }) => `${store.steps.greet.name}, tell me about yourself.`, // typed
next: 'done', // overrides __parent__
})
.build() validation
Calling .build() freezes the skill definition and validates:
nameis presententryis present and matches a registered step- At least one step exists
- No unresolved
__parent__sentinels remain (they must be overridden via.extend()or.register())
Triggers, if present, are appended to the description string for agent discoverability.
Handling validation errors
When the agent’s output doesn’t match a step’s ArkType schema, the engine:
- Fires
onStepValidationFailedwith the raw output, error message, and attempt number - Returns a
ValidationErrorResultto the agent withretry: true - The agent sees the error message and tries again
The skill author doesn’t need to handle this — the engine manages the retry loop automatically. But the onStepValidationFailed observer is invaluable during development:
skill({
// ...
observers: {
onStepValidationFailed: ({ step, raw, error, attempt }) => {
console.error(`Step "${step}" attempt ${attempt} failed validation: ${error}`);
console.error('Raw output:', JSON.stringify(raw, null, 2));
},
},
});
Testing validation paths
Use a function response in mockModel to return invalid output on the first attempt, then valid output on the retry:
import { runSkill, mockModel } from '@contentful/skill-kit/test';
const result = await runSkill(mySkill.build(), {
model: mockModel({
'my-step': (prompt) => {
if (prompt.includes('validation')) {
return { valid: 'output' };
}
return { bad: 'shape' }; // first call — triggers validation error + retry
},
}),
});
Standalone step() function
For shared, reusable steps defined outside a skill. These steps have no typed params or store — they default to unknown. Use __parent__ as the next transition, then override it with .extend().
import { step, type } from '@contentful/skill-kit';
export const gatherRepoFacts = step({
prompt: 'Inspect the repo and list languages, build tools, CI config.',
response: type({
languages: 'string[]',
buildTools: 'string[]',
ci: 'string | null',
}),
next: '__parent__',
});
The standalone step() validates that response and next are provided. The returned StepDefinition is frozen and has an .extend(overrides) method for inline customization.
Complete worked example
A deploy-check skill that gathers environment info, presents a plan, runs checks, tracks remediation tasks, and produces a report. Demonstrates act.askUser, act.plan, act.checklist, act.confirm, fragments, the store, declarative branching, and loop guards.
import { skill, type, prompt, fragment, render, act, terminal } from '@contentful/skill-kit';
// --- Fragments ---
const strictTone = fragment(
'strict-tone',
`Be precise and thorough. No hand-waving. Every claim must be backed
by a specific file path, command output, or config value.`,
);
// --- Schemas ---
const CheckResult = type({
name: 'string',
passed: 'boolean',
detail: 'string',
});
// --- Skill ---
export default skill({
name: 'deploy-check',
version: '1.0.0',
description: 'Validates deployment readiness across environments.',
entry: 'choose-env',
params: type({
defaultEnv: 'string = "staging"',
}),
finalOutput: type({
ready: 'boolean',
summary: 'string',
}),
})
// Step 1: Ask the user which environment to check
.step('choose-env', {
prompt: act.askUser({
type: 'structured',
question: 'Which environment should we validate?',
options: [
{ value: 'staging', label: 'Staging' },
{ value: 'production', label: 'Production', description: 'Live environment' },
{ value: 'local', label: 'Local dev' },
],
}),
response: type({ env: "'staging' | 'production' | 'local'" }),
next: 'plan-checks',
})
// Step 2: Present the check plan for approval
.step('plan-checks', {
prompt: act.plan({
summary: 'Deployment readiness checks',
steps: [
'Verify the build completes without errors',
'Run the full test suite',
'Confirm environment variables are set',
'Validate database migrations are current',
],
}),
response: type({ approved: 'boolean' }),
next: [{ to: 'run-checks', when: ({ response }) => response.approved }, { to: 'force-report' }],
})
// Step 3: Run the checks — store.steps['choose-env'].env is guaranteed (non-optional)
.step('run-checks', {
prompt: ({ store }) => prompt`
${strictTone}
Run deployment checks for the **${store.steps['choose-env'].env}** environment:
1. Verify the build completes without errors
2. Check that all tests pass
3. Confirm environment variables are set
4. Validate database migrations are current
Return each check with its pass/fail status and detail.
`,
response: type({ checks: CheckResult.array() }),
next: ({ response }) => (response.checks.every((c) => c.passed) ? 'approve' : 'remediate'),
})
// Step 4a: Remediation (if checks failed)
.step('remediate', {
prompt: ({ store, act }) => {
const failCount = store.steps['run-checks'].checks.filter((c) => !c.passed).length;
return [
act.checklist({
create: [
{ title: 'Fix failing deployment checks', status: 'in_progress' },
{ title: 'Re-run validation suite', status: 'pending' },
],
}),
prompt`
${strictTone}
${failCount} check(s) failed for **${store.steps['choose-env'].env}**.
Failed checks: ${JSON.stringify(
store.steps['run-checks'].checks.filter((c) => !c.passed),
null,
2,
)}
For each failure, propose a concrete fix. Then re-run the checks.
`,
];
},
response: type({ checks: CheckResult.array() }),
next: ({ response, attempts }) => {
if (response.checks.every((c) => c.passed)) return 'approve';
if (attempts >= 2) return 'force-report';
return 'self';
},
maxVisits: 3,
onMaxVisits: 'force-report',
})
// Step 4b: Approval (if all checks passed)
.step('approve', {
prompt: act.confirm({
message: 'All checks passed. Ready to generate the deployment report?',
defaultAnswer: 'yes',
}),
response: type({ approved: 'boolean' }),
next: [{ to: 'report', when: ({ response }) => response.approved }, { to: 'run-checks' }],
})
// Step 5a: Final report (happy path) — uses store for typed access
.step('report', {
prompt: ({ store }) => {
// store.steps['run-checks'] is guaranteed — no ?. needed
const checks = store.steps['run-checks'].checks;
const env = store.steps['choose-env'].env;
const table = render.table(checks, { columns: ['name', 'passed', 'detail'] });
const report = render.section(`Deployment Report: ${env}`, table);
return `Output the following deployment report exactly as shown:\n\n${report}`;
},
response: type({ ready: 'boolean', summary: 'string' }),
next: terminal,
})
// Step 5b: Force report (after max remediation attempts)
.step('force-report', {
prompt: ({ store }) => prompt`
Remediation attempts exhausted for **${store.steps['choose-env'].env}**.
Generate a summary noting which checks still fail and recommend manual intervention.
`,
response: type({ ready: 'boolean', summary: 'string' }),
next: terminal,
})
.build();
Testing the deploy-check skill
import test from 'node:test';
import assert from 'node:assert/strict';
import { runSkill, mockModel } from '@contentful/skill-kit/test';
import skill from './skill.js';
test('happy path: all checks pass', async () => {
const result = await runSkill(skill, {
params: { defaultEnv: 'staging' },
model: mockModel({
'choose-env': { env: 'staging' },
'plan-checks': { approved: true },
'run-checks': {
checks: [
{ name: 'build', passed: true, detail: 'Build succeeded' },
{ name: 'tests', passed: true, detail: '142 tests passed' },
],
},
approve: { approved: true },
report: { ready: true, summary: 'All checks passed for staging.' },
}),
});
assert.deepEqual(result.path, ['choose-env', 'plan-checks', 'run-checks', 'approve', 'report']);
assert.equal((result.response as { ready: boolean }).ready, true);
});
test('remediation path: failed checks trigger fixes', async () => {
const result = await runSkill(skill, {
model: mockModel({
'choose-env': { env: 'production' },
'plan-checks': { approved: true },
'run-checks': {
checks: [
{ name: 'build', passed: true, detail: 'OK' },
{ name: 'migrations', passed: false, detail: 'Pending migration' },
],
},
remediate: {
checks: [
{ name: 'build', passed: true, detail: 'OK' },
{ name: 'migrations', passed: true, detail: 'Migration applied' },
],
},
approve: { approved: true },
report: { ready: true, summary: 'Fixed and ready.' },
}),
});
assert.ok(result.path.includes('remediate'));
assert.ok(result.path.includes('approve'));
});
See also
- Composite Skills — combine multiple workflow skills under a single dispatcher with shared references