Create skills and scripts for your Coding Agents

I’m a big fan of the coding agents we have at our disposal. I use OpenCode a lot myself, switch to GitHub Copilot regularly, and several of my colleagues use Claude Code.

All of these coding agents have similar capabilities, but they work slightly different. When you are doing the same thing over and over again, it makes sense to create generic commands for it. Some examples can also be found in the Awesome Copilot repository.
Another very useful feature is Agent Skills. Commands and skills overlap in some areas, but they differ in an important way.

The description for commands is:

Custom commands let you specify a prompt you want to run when that command is executed in the TUI.

The description for skills is:

Agent skills let OpenCode discover reusable instructions from your repo or home directory. Skills are loaded on-demand via the native skill tool—agents see available skills and can load the full content when needed.

Can you spot the difference? It’s this: “loaded on-demand”.
Skills are only used when the agent determines they are relevant, so the frontmatter fields are important.
You can also grant permissions to skills, which saves you from having to press the “Allow” button every time.

Skills with scripts

One lesser-known feature is that skills can invoke scripts.
You might be asking yourself, “Why would I do that?” The main reason is consistency.
All of the agents we use are capable of creating and invoking scripts themselves. However, those scripts may differ from one session to another, which can lead to inconsistent results.
Another benefit is that it reduces token usage and compute. If a script already exists, the model does not need to create one first. I’m sure you can think of many repetitive tasks you do during the day where an agent could help.

For me, one example is creating a summary of changes between releases. A prompt like this:

Provide the changes between commit 1234abc and 5678def, and make sure they are clear for both engineers and product owners. Create a markdown file for this.

You will usually get a well-written change document based on the Git commits. However, the format can differ between runs. My team and I prefer consistency, so I created a command, a skill, and a script for this workflow.

The command

The command for this change summary is very explicit about what it should do. It resides in the .opencode/commands folder in a file called create-change-summary.md.
If you use a different agent, you might need to put it in a different folder.

---
description: Generate structured release notes between two commits
agent: build
---

Read the skill definition at `.opencode/skills/create-change-summary/SKILL.md` and follow it.

The skill should:

1. Ask for a start commit and end commit when they are not provided.
2. Run the PowerShell router to validate commits, collect git artifacts, and scaffold output.
3. Produce a final markdown release-note document with frontmatter and the required sections.

You can invoke it with /create-change-summary. If you do not specify the commits, it will ask where to start and end the analysis. I often use something like /create-change-summary from '1234abc' to '5678def'.
You are free to add whatever steps or instructions to this command/prompt.

I’m also explicitly stating which skill to use. In theory, that should not be necessary because the agent should be able to determine that by itself, but a little guidance helps.

The skill

In the .opencode/skills folder, I created a subfolder called create-change-summary. Using the same name is not required. You can call it whatever you like, but descriptive names make life easier.

Inside it, I have a file called SKILL.md with this content.

---
name: create-change-summary
description: Generate structured release notes between two commits with a PowerShell router workflow
---

## Purpose

Create product- and frontend-focused release notes for a commit range, with a consistent, dated filename and repeatable collection workflow.

## When to Use

- User runs `/create-change-summary`
- Team needs a scoped summary between two commits
- Product owners and frontend engineers/architects need a high-level but actionable change overview

## Inputs

- `fromCommit` (required, ask if missing)
- `toCommit` (required, ask if missing)
- `branch` (optional, default: `main`)
- `outputPath` (optional)

## Required Output Structure

The generated markdown must include:

1. Frontmatter
2. Added endpoints
3. Changed endpoints
4. Breaking changes
5. New functionality
6. High level technical changes

## Output Filename Convention

If `outputPath` is not provided, generate:

`docs/release-notes/YYYYMMdd-change-summary-<branch>-<from7>-to-<to7>.md`

Example:

`docs/release-notes/20260325-change-summary-main-93a7eeb-to-aa1ac8b.md`

## Workflow

### Step 1: Gather inputs

If `fromCommit` or `toCommit` is missing, ask the user.

### Step 2: Run router validation + collection + scaffold

Run:

`pwsh -File .opencode/skills/create-change-summary/router.ps1 run --from <fromCommit> --to <toCommit> --branch <branch> [--output <outputPath>]`

The router returns JSON including:

- resolved output path
- artifacts directory
- scaffold file path

### Step 3: Read artifacts and draft release notes

Use collected artifacts to populate all required sections:

- commit list
- changed file list
- controller/model/infra diffs

Audience focus:

- Product owners: business impact, capabilities, risks
- Frontend engineers/architects: endpoint contract changes, breaking changes, integration implications

### Step 4: Write final file

Overwrite scaffold with complete release notes content.

### Step 5: Confirm

Return:

- final output path
- short summary of major themes
- explicit callout for any breaking changes

## Notes

- Keep language concise and practical.
- Do not invent changes not present in git artifacts.
- Prefer endpoint/contract-level clarity over internal low-level details.

This text contains all the information required to complete the change summary file. I’m also referring to a PowerShell script inside the skill. That makes the workflow very explicit.
Inside the script, all of the steps are executed:

  1. Validate that the commits exist
  2. Resolve the required paths
  3. Collect all change information from the Git history
  4. Write down all information based on the template
param(
    [Parameter(Position = 0)]
    [ValidateSet('validate', 'default-output', 'collect', 'scaffold', 'run')]
    [string]$Command,

    [string]$From,
    [string]$To,
    [string]$Branch = 'main',
    [string]$Output,
    [string]$ArtifactDir,
    [string]$ScaffoldPath
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

function Invoke-Git {
    param(
        [Parameter(Mandatory = $true)]
        [string[]]$Arguments
    )

    $result = & git @Arguments 2>&1
    if ($LASTEXITCODE -ne 0) {
        $msg = ($result | Out-String).Trim()
        throw "git $($Arguments -join ' ') failed: $msg"
    }

    return ($result | Out-String)
}

function Test-CommitExists {
    param([string]$Commit)

    try {
        Invoke-Git -Arguments @('cat-file', '-e', "$Commit^{commit}") | Out-Null
        return $true
    }
    catch {
        return $false
    }
}

function Get-DateStamp {
    return (Get-Date).ToString('yyyyMMdd')
}

function Get-ShortSha {
    param([string]$Commit)

    return (Invoke-Git -Arguments @('rev-parse', '--short=7', $Commit)).Trim()
}

function New-DefaultOutputPath {
    param(
        [string]$FromCommit,
        [string]$ToCommit,
        [string]$TargetBranch
    )

    $dateStamp = Get-DateStamp
    $fromShort = Get-ShortSha -Commit $FromCommit
    $toShort = Get-ShortSha -Commit $ToCommit

    return "docs/release-notes/$dateStamp-change-summary-$TargetBranch-$fromShort-to-$toShort.md"
}

function New-ArtifactDirectory {
    param(
        [string]$FromCommit,
        [string]$ToCommit
    )

    $dateStamp = Get-DateStamp
    $fromShort = Get-ShortSha -Commit $FromCommit
    $toShort = Get-ShortSha -Commit $ToCommit

    return ".tmp/release-notes/$dateStamp-$fromShort-$toShort"
}

function Ensure-Directory {
    param([string]$Path)

    if (-not (Test-Path -LiteralPath $Path)) {
        New-Item -ItemType Directory -Path $Path -Force | Out-Null
    }
}

function Resolve-Paths {
    param(
        [string]$FromCommit,
        [string]$ToCommit,
        [string]$TargetBranch,
        [string]$OutputPath,
        [string]$ArtifactsPath,
        [string]$ScaffoldFilePath
    )

    $resolvedOutput = if ([string]::IsNullOrWhiteSpace($OutputPath)) {
        New-DefaultOutputPath -FromCommit $FromCommit -ToCommit $ToCommit -TargetBranch $TargetBranch
    }
    else {
        $OutputPath
    }

    $resolvedArtifacts = if ([string]::IsNullOrWhiteSpace($ArtifactsPath)) {
        New-ArtifactDirectory -FromCommit $FromCommit -ToCommit $ToCommit
    }
    else {
        $ArtifactsPath
    }

    $resolvedScaffold = if ([string]::IsNullOrWhiteSpace($ScaffoldFilePath)) {
        $resolvedOutput
    }
    else {
        $ScaffoldFilePath
    }

    return [PSCustomObject]@{
        OutputPath = $resolvedOutput
        ArtifactDir = $resolvedArtifacts
        ScaffoldPath = $resolvedScaffold
    }
}

function Run-Validate {
    param(
        [string]$FromCommit,
        [string]$ToCommit
    )

    if ([string]::IsNullOrWhiteSpace($FromCommit) -or [string]::IsNullOrWhiteSpace($ToCommit)) {
        throw 'Both --from and --to are required.'
    }

    if (-not (Test-CommitExists -Commit $FromCommit)) {
        throw "Start commit not found: $FromCommit"
    }

    if (-not (Test-CommitExists -Commit $ToCommit)) {
        throw "End commit not found: $ToCommit"
    }

    $rangeCountText = (Invoke-Git -Arguments @('rev-list', '--count', "$FromCommit..$ToCommit")).Trim()
    [int]$rangeCount = 0
    if (-not [int]::TryParse($rangeCountText, [ref]$rangeCount)) {
        throw "Unable to parse commit count for range $FromCommit..$ToCommit"
    }

    if ($rangeCount -le 0) {
        throw "Commit range has no commits: $FromCommit..$ToCommit"
    }

    return [PSCustomObject]@{
        valid = $true
        commitCount = $rangeCount
        fromCommit = $FromCommit
        toCommit = $ToCommit
    }
}

function Run-Collect {
    param(
        [string]$FromCommit,
        [string]$ToCommit,
        [string]$TargetBranch,
        [string]$ArtifactsPath
    )

    Ensure-Directory -Path $ArtifactsPath

    $files = @{
        commits = Join-Path $ArtifactsPath 'commits.txt'
        commitMessages = Join-Path $ArtifactsPath 'commit-messages.txt'
        changedFiles = Join-Path $ArtifactsPath 'changed-files.txt'
        changedFilesStatus = Join-Path $ArtifactsPath 'changed-files-status.txt'
        controllerDiff = Join-Path $ArtifactsPath 'controllers.diff'
        apiModelDiff = Join-Path $ArtifactsPath 'api-models.diff'
        serviceModelDiff = Join-Path $ArtifactsPath 'service-models.diff'
        pythonApiDiff = Join-Path $ArtifactsPath 'python-api.diff'
        infraDiff = Join-Path $ArtifactsPath 'infra.diff'
        stats = Join-Path $ArtifactsPath 'stats.txt'
    }

    Invoke-Git -Arguments @('log', '--oneline', '--decorate', '--reverse', "$FromCommit..$ToCommit") | Set-Content -LiteralPath $files.commits -Encoding UTF8
    Invoke-Git -Arguments @('log', '--format=%H|%ad|%an|%s', '--date=iso', '--reverse', "$FromCommit..$ToCommit") | Set-Content -LiteralPath $files.commitMessages -Encoding UTF8
    Invoke-Git -Arguments @('diff', '--name-only', "$FromCommit..$ToCommit") | Set-Content -LiteralPath $files.changedFiles -Encoding UTF8
    Invoke-Git -Arguments @('diff', '--name-status', "$FromCommit..$ToCommit") | Set-Content -LiteralPath $files.changedFilesStatus -Encoding UTF8
    Invoke-Git -Arguments @('diff', '-U1', "$FromCommit..$ToCommit", '--', 'src/dotnet/Symson.SmartPricing.Api/Controllers/*.cs') | Set-Content -LiteralPath $files.controllerDiff -Encoding UTF8
    Invoke-Git -Arguments @('diff', '-U1', "$FromCommit..$ToCommit", '--', 'src/dotnet/Symson.SmartPricing.Api/Models/**/*.cs') | Set-Content -LiteralPath $files.apiModelDiff -Encoding UTF8
    Invoke-Git -Arguments @('diff', '-U1', "$FromCommit..$ToCommit", '--', 'src/dotnet/Symson.SmartPricing.Services/Models/**/*.cs') | Set-Content -LiteralPath $files.serviceModelDiff -Encoding UTF8
    Invoke-Git -Arguments @('diff', '-U1', "$FromCommit..$ToCommit", '--', 'src/python/pricing_skeleton_backend/src/pricing_skeleton_backend/api/**/*.py') | Set-Content -LiteralPath $files.pythonApiDiff -Encoding UTF8
    Invoke-Git -Arguments @('diff', '-U1', "$FromCommit..$ToCommit", '--', 'infrastructure/**/*.bicep', 'infrastructure/**/*.bicepparam', 'infrastructure/**/*.yml', 'infrastructure/**/*.yaml', 'infrastructure/**/*.xml') | Set-Content -LiteralPath $files.infraDiff -Encoding UTF8

    $commitCount = (Invoke-Git -Arguments @('rev-list', '--count', "$FromCommit..$ToCommit")).Trim()
    $changedCount = (Invoke-Git -Arguments @('diff', '--name-only', "$FromCommit..$ToCommit") | Measure-Object -Line).Lines

    @(
        "branch=$TargetBranch"
        "from=$FromCommit"
        "to=$ToCommit"
        "commit_count=$commitCount"
        "files_changed=$changedCount"
    ) | Set-Content -LiteralPath $files.stats -Encoding UTF8

    return [PSCustomObject]@{
        artifactDir = $ArtifactsPath
        files = $files
    }
}

function New-ScaffoldContent {
    param(
        [string]$FromCommit,
        [string]$ToCommit,
        [string]$TargetBranch
    )

    $dateOnly = (Get-Date).ToString('yyyy-MM-dd')
    $fromShort = Get-ShortSha -Commit $FromCommit
    $toShort = Get-ShortSha -Commit $ToCommit

    return @"
---
title: "Change Summary: $TargetBranch $fromShort..$toShort"
branch: "$TargetBranch"
from_commit: "$FromCommit"
to_commit: "$ToCommit"
date: "$dateOnly"
audience:
  - "Product Owners"
  - "Frontend Engineers"
  - "Frontend Architects"
---

# Change summary ($TargetBranch): `$fromShort` → `$toShort`

## Added endpoints

<!-- Fill based on controller and backend route diffs -->

## Changed endpoints

<!-- Fill contract/behavior changes for existing endpoints -->

## Breaking changes

<!-- Fill only confirmed breaking changes -->

## New functionality

<!-- Fill business-facing capability additions -->

## High level technical changes

<!-- Fill architecture/infrastructure/CI/reliability changes -->
"@
}

function Run-Scaffold {
    param(
        [string]$FromCommit,
        [string]$ToCommit,
        [string]$TargetBranch,
        [string]$TargetScaffoldPath
    )

    $parent = Split-Path -Parent $TargetScaffoldPath
    if (-not [string]::IsNullOrWhiteSpace($parent)) {
        Ensure-Directory -Path $parent
    }

    $content = New-ScaffoldContent -FromCommit $FromCommit -ToCommit $ToCommit -TargetBranch $TargetBranch
    Set-Content -LiteralPath $TargetScaffoldPath -Encoding UTF8 -Value $content

    return [PSCustomObject]@{
        scaffoldPath = $TargetScaffoldPath
    }
}

function Write-Json {
    param([object]$Object)

    $Object | ConvertTo-Json -Depth 10
}

switch ($Command) {
    'validate' {
        $result = Run-Validate -FromCommit $From -ToCommit $To
        Write-Json -Object $result
        break
    }
    'default-output' {
        if ([string]::IsNullOrWhiteSpace($From) -or [string]::IsNullOrWhiteSpace($To)) {
            throw 'Both --from and --to are required for default-output.'
        }

        $path = New-DefaultOutputPath -FromCommit $From -ToCommit $To -TargetBranch $Branch
        Write-Json -Object ([PSCustomObject]@{ outputPath = $path })
        break
    }
    'collect' {
        Run-Validate -FromCommit $From -ToCommit $To | Out-Null
        $paths = Resolve-Paths -FromCommit $From -ToCommit $To -TargetBranch $Branch -OutputPath $Output -ArtifactsPath $ArtifactDir -ScaffoldFilePath $ScaffoldPath
        $result = Run-Collect -FromCommit $From -ToCommit $To -TargetBranch $Branch -ArtifactsPath $paths.ArtifactDir
        Write-Json -Object ([PSCustomObject]@{
                artifactDir = $result.artifactDir
                files = $result.files
            })
        break
    }
    'scaffold' {
        Run-Validate -FromCommit $From -ToCommit $To | Out-Null
        $paths = Resolve-Paths -FromCommit $From -ToCommit $To -TargetBranch $Branch -OutputPath $Output -ArtifactsPath $ArtifactDir -ScaffoldFilePath $ScaffoldPath
        $result = Run-Scaffold -FromCommit $From -ToCommit $To -TargetBranch $Branch -TargetScaffoldPath $paths.ScaffoldPath
        Write-Json -Object ([PSCustomObject]@{ scaffoldPath = $result.scaffoldPath })
        break
    }
    'run' {
        $validation = Run-Validate -FromCommit $From -ToCommit $To
        $paths = Resolve-Paths -FromCommit $From -ToCommit $To -TargetBranch $Branch -OutputPath $Output -ArtifactsPath $ArtifactDir -ScaffoldFilePath $ScaffoldPath
        $collect = Run-Collect -FromCommit $From -ToCommit $To -TargetBranch $Branch -ArtifactsPath $paths.ArtifactDir
        $scaffold = Run-Scaffold -FromCommit $From -ToCommit $To -TargetBranch $Branch -TargetScaffoldPath $paths.OutputPath

        Write-Json -Object ([PSCustomObject]@{
                valid = $validation.valid
                branch = $Branch
                fromCommit = $From
                toCommit = $To
                commitCount = $validation.commitCount
                outputPath = $paths.OutputPath
                scaffoldPath = $scaffold.scaffoldPath
                artifactDir = $collect.artifactDir
                files = $collect.files
            })
        break
    }
    default {
        throw 'Command is required. Use one of: validate, default-output, collect, scaffold, run.'
    }
}

Because everything runs inside the agent, you still get the full power of the underlying model. With this setup, the agent can create a nice summary even when PR descriptions are incomplete, as long as the underlying changes are clear enough.

To conclude

This post is a small example of how you can benefit from adding skills to your coding agents. I do quite a lot of work from the CLI, and by exposing these scripts as skills to OpenCode (or any other agent), it can run them for me in a predictable way.
Of course, use this with caution. You do not want your coding agent running destructive skills without you knowing.