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
1234abcand5678def, 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:
- Validate that the commits exist
- Resolve the required paths
- Collect all change information from the Git history
- 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.
