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();
FieldTypeRequiredDescription
namestringYesSkill identifier. Used in build output directory name and generated SKILL.md.
entrystringYesName of the first step to execute. Must match a registered step name.
versionstringNoSemver version string. Defaults to '0.0.0'.
descriptionstringNoHuman-readable description. Included in generated SKILL.md frontmatter.
triggersstring[]NoTrigger keywords appended to the description for agent discoverability.
paramstype.AnyNoArkType schema for global skill params. Validated on start, typed everywhere.
finalOutputtype.AnyNoSchema for the skill’s “return value”. Validated when the workflow terminates.
observersObserverMapNoRead-only lifecycle callbacks for logging, telemetry, and audit.
skillMdstring | ((skill) => string)NoCustom SKILL.md template. Overrides the generated default.
systemstringNoSystem-level persona prepended to the preamble. Steps inherit it.
argumentHintstringNoAutocomplete hint text. Emitted as argument-hint in SKILL.md frontmatter.
argumentsstring | string[]NoNamed positional arguments for $name substitution in skill content. Emitted as arguments in frontmatter.
allowedToolsstring | string[]NoAdditional pre-approved tools. Build auto-includes CLI and MCP defaults.
pathsstring | string[]NoGlob patterns for file-based activation. Emitted as paths in frontmatter.
contextstringNoExecution context (e.g. 'fork'). Emitted as context in frontmatter.
licensestringNoLicense name or reference. Emitted as license in frontmatter.
compatibilitystringNoEnvironment requirements. Emitted as compatibility in frontmatter.
agentstringNoSubagent type when context: 'fork'. Emitted as agent in frontmatter.
modelstringNoModel override while skill is active. Emitted as model in frontmatter.
effortstringNoEffort level override. Emitted as effort in frontmatter.
disableModelInvocationbooleanNoPrevent auto-loading by the agent. Emitted as disable-model-invocation.
userInvocablebooleanNoWhether 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

FieldTypeRequiredDescription
promptstring | PromptPiece | PromptFnNoThe prose shown to the agent. Can be static, a segment, or a function.
responsetype.AnyNoArkType schema the agent’s response must match. Omit for pass-through.
nextstring | NextBranch[] | (ctx) => string | terminalYesWhere to go after this step. Static name, branches, function, terminal.
action{ run, input? }NoCLI-side side effect that runs after response validation.
save(ctx: { response, actionResult, store, params }) => unknownNoTransform what gets stored. Defaults to actionResult or response.
maxVisitsnumberNoExplicit visit limit for this step.
onMaxVisitsstringNoFallback step when maxVisits is exceeded.

PromptContext

Every dynamic prompt function receives a PromptContext object. These are the fields available:

FieldTypeDescription
storeStoreView<TSteps, TGuaranteed>Typed accessor for prior step results (guaranteed steps non-optional, branch targets use ?.)
paramsTParamsGlobal skill params (config defaults + runtime overrides).
refsReferenceLoaderLazy loader for reference files: refs.load('file.md'), refs.asset('path').
attemptsnumberHow many times this step has been visited (zero-indexed, useful for retries).
hostHandshakeHost info: { host: string, toolsAvailable: string[] }.
actActBuilderPrimitive directive builders: askUser, confirm, plan, etc.
systemSystemBuilderSystem 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.

ConfigBehavior
No maxVisits declared, step in a cycleImplicit limit of 10 visits. Throws CycleGuardError when exceeded.
maxVisits without onMaxVisitsThrows CycleGuardError when the limit is hit (fail-closed).
maxVisits + onMaxVisitsRedirects 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:

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:

  1. Fires onStepValidationFailed with the raw output, error message, and attempt number
  2. Returns a ValidationErrorResult to the agent with retry: true
  3. 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