- TypeScript 99.9%
- Shell 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
msw 3 renames onUnhandledRequest to onUnhandledFrame. TypeScript stays on 6.x. |
||
| .agent/skills/qa | ||
| .claude | ||
| .forgejo/workflows | ||
| .githooks | ||
| adr | ||
| bin | ||
| docs | ||
| eval | ||
| skills/storyblok-content-ops | ||
| src | ||
| .env.qa.template | ||
| .git-blame-ignore-revs | ||
| .gitignore | ||
| .lintstagedrc.json | ||
| .prettierignore | ||
| .prettierrc.json | ||
| AGENTS.md | ||
| CLAUDE.md | ||
| eslint.config.ts | ||
| GAPS.md | ||
| LICENSE | ||
| package.json | ||
| pnpm-lock.yaml | ||
| README.md | ||
| tsconfig.json | ||
| tsdown.config.ts | ||
| vitest.config.ts | ||
| vitest.setup.ts | ||
storyblok-agent
Find and change Storyblok content from the command line.
Ask where a term occurs and get back the exact locations, then rewrite only those fields. Renaming a product across a hundred stories, or fixing one headline, takes a command instead of a trip through the editor.
Built for AI agents and scripts: field-level results and writes keep whole stories out of the caller's context.
What an agent does with it
Our product "Storyblok Labs" is now "Storyblok Studio". Rename it everywhere.
sba stories replace "Storyblok Labs" --with "Storyblok Studio"
{"id":123,"slug":"blog/launch","changed":["/content/body/0/headline","/content/body/1/text"]}
{"id":124,"slug":"pages/about","changed":["/content/intro"]}
{"note":"50 stories rewritten, 50 of them changed.","stories":50,"changed":50}
The free trial is now 30 days instead of 14. Update every mention so each sentence still reads right.
sba stories grep '14[- ]day|two weeks' --regex
{"story":456,"slug":"blog/pricing-faq","path":"/content/body/2/text","value":"Every plan starts with a 14-day trial, which is two weeks to try it with your team.","term":"14[- ]day|two weeks"}
Each match comes back with the string it sits in, so the agent reads the sentence, not the story, and rewrites only the part that changes:
sba stories update blog/pricing-faq --edit /content/body/2/text \
--old 'a 14-day trial, which is two weeks to' \
--new 'a 30-day trial, which is a full month to'
{"id":456,"slug":"blog/pricing-faq","changed":["/content/body/2/text"]}
How many stories have no meta title?
sba stories select /content/meta_title --absent | wc -l
Every "Learn more" button should name the product its story is about.
sba stories grep '^Learn more$' --regex --select /content/body/0/headline \
| jq -c '{story, ops:[{op:"replace", path, value:("Explore " + (.selected["/content/body/0/headline"] | split(" — ")[0]))}]}' \
| sba stories apply
{"id":123,"slug":"blog/launch","changed":["/content/body/4/label"]}
{"note":"50 stories written, 50 of them changed, 0 failed.","stories":50,"changed":50,"failed":0}
"Pricing 2023" is superseded by the pricing page. Delete it without breaking anything that links to it.
sba stories delete blog/pricing-2023 --replace-with blog/pricing --publish
{"id":789,"slug":"blog/pricing-2023","deleted":true,"descendants_deleted":0,"replaced":[{"id":123,"slug":"pages/home","updated_at":"2026-09-17T11:00:00.000Z","changed":["/content/body/2/link/id","/content/body/2/link/cached_url"]}]}
Without --replace-with the delete is refused and lists every link it would
break. Assets work the same way.
Export the images nobody uses, then delete the ones older than this year.
sba assets unused > unused.jsonl
jq -r '[.id, .filename, .created_at] | @csv' unused.jsonl > unused.csv
jq -c 'select(.created_at < "2026-01-01")' unused.jsonl | sba assets delete
{"id":456,"deleted":true}
{"note":"212 assets of 1840 are used by no story, of 3951 stories read.","assets":1840,"unused":212,"stories":3951}
Every story is read once, in every language and its published version, and each delete checks for uses again before the file goes.
Which images on the blog have no alt text? Give me a CSV.
sba assets missing-alt --starts-with blog/ > missing-alt.jsonl
jq -r '[.kind, .id // .asset, .slug, .path, .filename] | @csv' missing-alt.jsonl > missing-alt.csv
missing-alt.jsonl:
{"kind":"asset","id":456,"filename":"https://a.storyblok.com/f/12345/1200x800/abc/hero.png","alt":""}
{"kind":"use","asset":457,"filename":"https://a.storyblok.com/f/12345/400x400/def/logo.png","story":123,"slug":"blog/launch","path":"/content/body/0/image/alt","library_alt":"The logo"}
Both are checked: the library's alt text, and the copy each story keeps per image, which is what the page renders.
We wrote alt text for the logos in the library. Make the pages use it.
sba assets push-alt --in-folder Logos --publish
{"id":123,"slug":"blog/launch","updated_at":"2026-09-17T11:00:00.000Z","changed":["/content/body/0/image/alt"]}
{"note":"38 uses in 31 stories got alt text.","stories":31,"uses":38}
Only empty alt text is filled; --overwrite also replaces text that differs.
That rename was wrong. Put it back.
sba undo --last
{"reverts":0,"kind":"story.patch","id":123,"slug":"blog/launch","action":"reverted","reverted":["/content/body/0/headline"]}
{"note":"journaled","change":"01J8Z3Q4N5W6X7Y8Z9A0B1C2D3","writes":50}
Every write is journaled, one change set per command: updates, publishes, deletes, assets and their folders. An undo puts back what the write changed and nothing else — an edit made since, elsewhere in the story, is kept, and one to the same field refuses the whole change set instead of overwriting it. A deleted folder is created again with the links between its stories mended.
Results go to stdout, one per line; note lines go to stderr.
See Performance for measured session costs and an MCP comparison.
Setup
Install from the forge's public npm registry:
npm config set @markus:registry https://code.senpen.eu/api/packages/markus/npm/
npm install -g @markus/storyblok-agent
In a project, put the first line in the project's .npmrc instead, so everyone
who installs it resolves the same way:
@markus:registry=https://code.senpen.eu/api/packages/markus/npm/
The binary is sba (storyblok-agent also works).
The skill
The documentation is also an agent skill, so the agent reads the same pages you
do. Install it with skills, either
from the forge:
npx skills add https://code.senpen.eu/markus/storyblok-agent.git --skill storyblok-content-ops
or, if the package is already a dependency of your project
(npm install -D @markus/storyblok-agent), from node_modules, which keeps the
skill on the version you install:
npx skills add ./node_modules/@markus/storyblok-agent --skill storyblok-content-ops
Either writes it to .claude/skills/ and the equivalent for whatever other
agents you pick; -g installs for every project instead. --skill matters on
the forge source: a clone also carries the skills this repo uses on itself.
Usage
export STORYBLOK_SPACE=12345
export STORYBLOK_TOKEN=your-personal-access-token
sba stories grep Foo --starts-with blog/
sba stories update 123 --set /content/body/0/headline="Hello Bar"
sba stories replace Foo --with Bar --dry-run
sba components get teaser --example
The space and the token can also come from a .env file, a
storyblok-agent.config.* file, or a .storyblok-agentrc. See
configuration.
Every command prints JSON on stdout, one document per line, so output pipes into
jq.
Performance
A four-part job against a space of 150 stories, each carrying about 17KB of nested content: rename a product wherever it occurs across 50 of them, change one story's headline, count the stories missing a field, and rewrite 50 button labels so each names the product its own story is about.
One Claude Code session on Sonnet did all four correctly for $0.30, in 18
turns and under 3 minutes, using 16 tool calls — nine of them sba. No story
was read in full to change a field in it.
Recorded 2026-09-16 in the run results. The harness documents the seed, setup, and limitations.
Why a CLI
Pipelines such as | jq and > file transform and store results without
sending them through the model. Pointer-based commands also let the caller
change a field without supplying the whole story.
In the paired run below, tool results reaching the model totaled 2.9MB through Storyblok's MCP server and 50KB through this CLI. Tool input totaled 1.57MB and 7KB respectively, largely because the MCP run sent whole story bodies for updates.
What that costs in practice
The same four-part job was run through Storyblok's MCP server and this CLI. Both completed it correctly. These figures are from one paired run, separate from the $0.30 session above:
| MCP server | storyblok-agent | |
|---|---|---|
| Cost | $17.70 | $0.41 |
| Tool calls | ~330 | 20 |
| Turns | 57 | 22 |
| Wall clock | 23 min | 4 min |
These measurements describe the tested tools and workflow. The MCP run used field selection for listings, then introduced a local Python script and direct API calls for bulk updates. See the evaluation harness for the setup and limitations.
Docs
The agent skill ships in skills/storyblok-content-ops/:
- Concepts: setup, configuration, output, pointers, scoping, bulk reads, safe writes.
- Stories:
list,search,grep,select,get,create,update,replace,apply,publish,unpublish,restore,delete. - Recipes: tasks that take more than one command.
- Components: what a space defines, and how to build a block.
- Assets: the asset library and its folders.
- Undo:
undo,history, and what a change set is. - Gaps: what this tool does not do yet, and what to reach for meanwhile.
Run sba --help for the full command list, or sba <command> --help for one
command.
Not this tool
This tool is about content operations only. Schema changes and content migrations are Storyblok CLI territory.