@markus/storyblok-agent (0.1.150)

Published 2026-09-19 10:52:39 +02:00 by markus

Installation

@markus:registry=
npm install @markus/storyblok-agent@0.1.150
"@markus/storyblok-agent": "0.1.150"

About this package

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: nothing has to download a whole story to change a line of it, so the work fits in a context window and in a CI job.

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.

The agent finds locations, not documents, and writes back to those locations. Stories never pass through its context.

A job of that kind, given as one prompt against 150 stories of about 17KB each (2.5MB of content), cost $0.30 on Sonnet. 48KB of tool output reached the model, it typed 4.7KB of tool input, and wrote 4,230 output tokens in total.

Through Storyblok's MCP server the same job cost 43 times as much. See What that costs in practice.

Setup

The package is not on npm. It is published to the forge's registry, which serves it to anyone without a login:

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. The harness holds the seed, how a run is set up, and what the numbers do not show.

Why a CLI and not an MCP server

An MCP tool hands its result to the model and nowhere else. The protocol has no way to pass a result to another tool or write it to a file, so every byte a server returns is paid for in context, and every byte sent back is paid for in output tokens. A command has stdout: | jq and > file move data at no cost to the conversation at all.

That is invisible until a job touches content at volume. Running the job above both ways, tool results reaching the model came to 2.9MB through MCP against 50KB through this CLI, and the model had to type out 1.57MB of tool input against 7KB — most of it whole story bodies on their way back to the API.

Field selection does not close the gap. It trims a listing, but changing one nested field still means fetching the whole story and sending the whole story back. Pointers are the thing that does close it: ask where a term occurs, get locations, write to those locations, and the document never moves.

What that costs in practice

Storyblok publishes an MCP server over the same Management API, which makes it a good way to put a number on the paragraph above. Given the job word for word, both finished all four parts correctly. Both columns come from one paired run, so they are comparable to each other rather than to the figure above, which is a later session:

MCP server storyblok-agent
Cost $17.70 $0.41
Tool calls ~330 20
Turns 57 22
Wall clock 23 min 4 min

The gap is the protocol's, not the server's. The server does try to work around the limitation — it offers field selection, and the run used it on every listing — but any MCP server over a document API would land in the same place, because changing a nested field still means the whole document travels through the conversation both ways.

The agent reached that conclusion on its own. Partway in it stepped outside the server, wrote a transform.py that replaced strings recursively and derived each product name by splitting the hero headline, dumped story JSON to disk, and drove the script over it across nine subagents:

This is far more efficient to do via direct Management API calls [...] so I don't have to paste huge JSON blobs through the conversation for each of the 10 stories.

Docs

The documentation is also the agent skill that ships with the package, so an agent can load the same pages you read. Setup above installs it; the pages are skills/storyblok-content-ops/ either way.

  • 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.

Dependencies

Dependencies

ID Version
@oclif/core ^5.0.0
@storyblok/api-client ^0.7.3
@storyblok/management-api-client ^0.7.3
c12 4.0.0-rc.1
pathe ^2.0.3
proper-lockfile ^4.1.2

Development dependencies

ID Version
@eslint/js ^10.0.1
@types/node ^24.11.0
@types/proper-lockfile ^4.1.4
@vitest/coverage-v8 ^5.0.1
@vitest/eslint-plugin ^1.6.27
eslint ^10.10.0
eslint-config-prettier ^10.1.8
eslint-plugin-import-x ^4.17.1
eslint-plugin-n ^18.3.0
globals ^17.12.0
jiti ^2.7.0
lint-staged 17.5.1
msw ^2.12.9
prettier ^3.9.6
publint ^0.3.24
tsdown ^0.23.0
typescript 6.0.3
typescript-eslint ^8.70.0
vitest ^5.0.1

Keywords

storyblok cms headless-cms content cli ai-agent automation
Details
npm
2026-09-19 10:52:39 +02:00
350
Markus Oberlehner
MIT
271 KiB
Assets (1)
Versions (10) View all
0.1.169 2026-10-07
0.1.168 2026-09-24
0.1.167 2026-09-23
0.1.166 2026-09-23
0.1.165 2026-09-23